<![CDATA[Stories by Tornike Gomareli on Medium]]> https://medium.com/@tgomareli?source=rss-e061295e669a------2 https://cdn-images-1.medium.com/fit/c/150/150/1*d55REgYsA2RYPK6mlMM-Hw.jpeg Stories by Tornike Gomareli on Medium https://medium.com/@tgomareli?source=rss-e061295e669a------2 Medium Tue, 14 Apr 2026 20:56:16 GMT <![CDATA[Swift + Zed = ❤️]]> https://tgomareli.medium.com/swift-zed-%EF%B8%8F-6b08de865425?source=rss-e061295e669a------2 https://medium.com/p/6b08de865425 Sat, 15 Mar 2025 12:25:54 GMT 2025-03-15T12:25:54.496Z

Intro

I’ve always been a text editor enthusiast, constantly searching for ways to maximize productivity through tools, keybindings, and automation. When you’re writing code for seven, eight, or even more hours each day, having a powerful and efficient editing environment is must, at least for me. Unfortunately, most modern IDEs often are clunk, heavy with extra features which you are not using at all and its super slow, those reasons pushing me toward exploring alternatives like Emacs, Spacemacs, Neovim, and Vim.

My journey with hard core editors began in earnest when I wrote my first article about Emacs in early 2017. Since then, I’ve experimented extensively, countless configurations, plugins, and approaches. For the past two years, Neovim has been my primary editor, and I’ve been incredibly productive with it. However, I recently noticed several experienced members of the Neovim community migrating toward an editor called Zed. Naturally, my curiosity was piqued.

After deeply exploring Zed Industries, their vision, design philosophy, tooling, and approach, it didn’t take me long to become genuinely impressed. If notable developers like Thorsten Ball have moved to Zed, that’s certainly a meaningful endorsement.

Today, I’m excited to share how I’ve configured Zed for my favorite programming language, Swift. Rather than an exhaustive overview of Zed itself (perhaps a topic for another article), this piece focuses on practical insights and actionable steps to optimize your Swift coding experience using Zed. Let’s dive in.

Extension

The first thing we need to do to is to install Swift extension inside Zed editor to have auto completion, diagnostics, compiler errors, warnings and other langauge features https://zed.dev/docs/languages/swift

But then we will have problem if we are using custom SPM packages, or workspaces and we need to navigate or get autocompletion and other important information about packages. The problem with Swift + iOS/macOS SDK is that the sourcekit-lsp provided by Apple doesn’t understand Xcode projects. So how could it provide the code completion if it doesn’t understand the project architecture, targets, dependencies, etc.? Exactly, it can’t.

So we need Build server protocol, which will parse all the project information and pass it to LSP.

Xcode-build-server

First, you have to download xcode-build-server. You can install it by just calling brew install xcode-build-server.

Once it’s done, only one more step is required. You have to create a buildServer.json that will tell LSP to communicate with xcode-build-server. To do that you can simply run one command (most likely, only once for your project lifetime):

# *.xcworkspace or *.xcodeproj should be unique.
# It can be omitted and will auto choose the unique workspace or project.
# if you use workspace:
xcode-build-server config -scheme <XXX> -workspace *.xcworkspace
# or if you only have a project file:
xcode-build-server config -scheme <XXX> -project *.xcodeproj

After that we need to change our Zed Settings

~/.config/zed/settings.json

If you want to load the LSP from a specific path

 "lsp": {
"sourcekit-lsp": {
"binary": {
"path": "/Applications/Xcode-16.1.0-Beta.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/sourcekit-lsp"
}
}
},
"languages": {
"Swift": {
"enable_language_server": true,
"language_servers": ["sourcekit-lsp"],
"formatter": "language_server",
"format_on_save": "on",
"tab_size": 2
}
}

After that u just need to restart Zed, and u then can jump to definations inside SPM packages, modules, workspaces and have full support of LSP inside your editor.

Build, run and Test

It seems right now we need some kind of approach to build your swift packages, test or run them. Obviously you can just open terminal, navigate to the directory and use swift CLI tool to do that, but you will agree that it is not best experience to everytime you will need build or run, to open terminal and work there, so most simplest solution here is to use Tasks feature of the Zed, define task and then whenever we will need to run it we can spawn task and run it, the task will run cmd inside the shell, lastly we will define the keymap which we will bind our task to, so on a keybindg we will run all the above things and will see terminal inside the Zed, which will run our build, test or run command.

Lets create build task first of all.

For opening tasks on Zed you just press

cmd + shift + p

And then type

open tasks

After opening

~/.config/zed/tasks.json

You will find out example json object, which shows how you can create your own tasks which can run cmds in shell. For now we need to create swift build task

U can copy and paste this json object

[
{
"label": "Swift Build",
"command": "swift build",
"env": {},
"use_new_terminal": false,
"allow_concurrent_runs": false,
"reveal": "always",
"reveal_target": "dock",
"hide": "on_success",
"shell": "system"
},
{
"label": "Swift Test",
"command": "swift test",
"env": {},
"use_new_terminal": false,
"allow_concurrent_runs": false,
"reveal": "always",
"reveal_target": "dock",
"hide": "never",
"shell": "system"
},
{
"label": "Swift Run",
"command": "swift run",
"env": {},
"use_new_terminal": false,
"allow_concurrent_runs": false,
"reveal": "always",
"reveal_target": "dock",
"hide": "on_success",
"shell": "system"
}
]

Explanation of options:

  • label: Task name displayed when spawning.
  • command: Shell command executed by the task.
  • use_new_terminal: If false, runs inside Zed’s integrated terminal.
  • allow_concurrent_runs: Prevents running the task multiple times simultaneously.
  • reveal: Controls when the terminal shows up ("always" recommended).
  • hide: Automatically hides terminal after success.
  • shell: Uses the default system shell (usually zsh or bash).

Save and your task is done, now we need to have some kind of keymap to run this task easily.

Lets open keymaps

~/.config/zed/keymaps.js

And after that its up to you in what keymap u will create it, read https://zed.dev/docs/key-bindings official docs for it,

but I will show how I have it.

 {
"context": "Editor && vim_mode == normal && vim_operator == none && !VimWaiting",
"bindings": {
"space l g": ["task::Spawn", { "task_name": "Lazygit" }],
"space s b": ["task::Spawn", { "task_name": "Swift Build" }],
"space s t": ["task::Spawn", { "task_name": "Swift Test" }],
"space s r": ["task::Spawn", { "task_name": "Swift Run" }],
"space e": "workspace::ToggleLeftDock",
"space o r": "projects::OpenRecent",
"cmd-shift-o": ["workspace::SendKeystrokes", "cmd-p"], /// Remapping cmd-p to more xcode like search withing project files
"space p t": "project_panel::ToggleFocus",
"space f e d": "zed::OpenSettings",
"space o t": "workspace::OpenInTerminal"
}
}

Cause I am using vim mode I need to check pre conditions before running keymaps, so for me swift build is running on space + s + b, swift testing on space + s + t and swift run on space + s + r

When u have on_success turned on, everyime build finishs succesfully it will hide itself from your context. If you will have never as a value, it will never hide, it is good for tests for me, cause I see and ensure what tests are running and what are green.

"hide": "on_success"
Swift build
Swift test

Zed still has a ways to go before it can truly replace Xcode. But it’s already a good step toward the simpler, faster, and more hackable editor many of us want, something stable and customizable, where working with Swift, building, and testing (at least our Swift packages) feels natural again.

Thanks for reading and happy coding 🚀

]]>
<![CDATA[Inspecting Swift’s Observation. Exploring Benefits, Issues and solutions.]]> https://tgomareli.medium.com/inspecting-swifts-observation-exploring-benefits-issues-and-solutions-00617f13d36c?source=rss-e061295e669a------2 https://medium.com/p/00617f13d36c Mon, 24 Jun 2024 11:06:28 GMT 2024-06-24T17:42:01.678Z

It was a really long road to Observation, as an engineers and part of the community we have tried and learned a lot of different approaches during these years and finally we are currently at the almost like a perfect solution the core problem of UI apps.

UIKit and Manual State Management

In the early days of iOS development, UIKit was the primary framework used to build user interfaces. State management was a manual and often cumbersome process. Developers had to handle state using instance variables, singletons, or other global objects. This approach, while flexible, had several drawbacks:

  • Complexity: Managing state manually often led to complex and error-prone code.
  • Scalability: As applications grew, maintaining and debugging state became increasingly difficult.
  • Thread Safety: Ensuring thread safety required additional effort, as UIKit was not inherently thread-safe.

Despite these challenges, this approach allowed for a high degree of control and customization, which was appreciated by experienced developers.

Reactive Programming: RxSwift

As applications became more complex, developers found better ways to manage state and asynchronous events. Reactive programming frameworks like RxSwift gained popularity. RxSwift introduced a new paradigm for state management, emphasizing declarative code and reactive streams.

Pros:

  • Declarative: Allowed developers to describe how state should change over time in a more readable and maintainable way.
  • Composition: Made it easier to compose and transform state streams.
  • Asynchronous Handling: Simplified handling of asynchronous events and state changes.

Cons:

  • Learning Curve: Steep learning curve for developers unfamiliar with reactive programming concepts.
  • Boilerplate: Required a significant amount of boilerplate code to set up and manage observables and subscriptions.

RxSwift was embraced by many for its powerful capabilities in managing complex state and asynchronous operations.

@State and @ObservedObject

The introduction of SwiftUI in 2019, brought a new declarative syntax and a modern approach to state management with @State and @ObservedObject.

  • @State: Designed for simple state that is local to a view. It allows developers to declare state variables that SwiftUI manages automatically.
  • @ObservedObject: Intended for more complex or shared state that needs to be observed by multiple views. It relies on the ObservableObject protocol and @Published properties to trigger updates.

SwiftUI follows the principle of “single source of truth”. The view tree can only be updated and the body re-evaluated by modifying the state subscribed by the View. In iOS 14, Apple added @StateObject, which complements the scenario of holding reference type instances in the View, making SwiftUI's state management more comprehensive.

But …..

When subscribing to reference types, a major issue with ObservableObject, which acts as the model type, is that it can't provide attribute-level subscriptions. In View, the subscription to ObservableObject is based on the entire instance. As long as any @Published property on the ObservableObject changes, it triggers the objectWillChange publisher of the entire instance to emit changes, causing all Views subscribed to this object to re-evaluate. In complex SwiftUI apps, this can lead to serious performance issues and hinder the scalability of the program, thus requiring careful design by the user to avoid large-scale performance degradation.

The lack of granularity in updates

Consider this example

class ProfileViewModel: ObservableObject {
@Published var name: String = ""
@Published var age: Int = 0
@Published var bio: String = ""
}

struct ProfileView: View {
@ObservedObject var viewModel: ProfileViewModel

var body: some View {
VStack {
Text("Name: \(viewModel.name)")
Text("Age: \(viewModel.age)")
// Bio is not used in this view
}
}
}

In this scenario, even if only the bio property changed, the entire ProfileView would be re-evaluated, despite bio not being used in the view. This led to unnecessary computations and potential performance issues, especially in large and complex view hierarchies.

Observation

At WWDC23, Apple introduced a brand-new Observation framework, hoping to solve the confusion and performance degradation issues related to state management in SwiftUI. The way this framework works seems magical, as you can achieve attribute-level subscriptions in the View without any special syntax or declarations, thereby avoiding any unnecessary refreshes. Today, we'll take a closer look at the story behind it.

This article will help you:

  • Understand what exactly the Observation framework does, and how it accomplishes it.
  • Understand the benefits it brings to us compared to previous solutions.
  • Understand some of the trade-offs we make when dealing with SwiftUI state management today.
  • Why it is only used with classes? Can we use Observation for value types?
  • What about Observation in iOS 13 ?

How the Observation framework works

Incorporating Observation into your project is quite simple. Just add the @Observable attribute before your model class declaration. This allows you to seamlessly integrate it into your View. Any changes to the stored or computed properties of the model instance will automatically trigger a re-evaluation of the View's body, ensuring that your View always reflects the current state of the model.

import SwiftUI
import Observation

@Observable final class HomeTask {
var title: String
var isCompleted: Bool

init(title: String, isCompleted: Bool) {
self.title = title
self.isCompleted = isCompleted
}
}

var homeTask = HomeTask(title: "Complete SwiftUI project", isCompleted: false)

struct ContentView: View {
var body: some View {
let _ = Self._printChanges()

VStack {
HStack {
Image(systemName: homeTask.isCompleted ? "checkmark.circle.fill" : "circle")
Text(homeTask.title)
.strikethrough(homeTask.isCompleted)
.foregroundColor(homeTask.isCompleted ? .gray : .black)
}
.padding()

Button("Mark as Completed") {
homeTask.isCompleted = true
}
.padding()
}
}
}

At first glance, this seems a bit magical: we didn’t declare any relationship between homeTask and ContentView. Simply by accessing isCompleted in View.body, we have completed the subscription. The specific use of @Observable in SwiftUI and the migration from ObservableObject to @Observable are fully explained in the WWDC Discover Observation in SwiftUI session. Here, I will focus things what is happening behind the hood . If you haven't watched the related video yet, I highly recommend that you do so first.

Now lets check if the problem of ObservedObject is solved here, before diving deeply. Let’s add domain based property in our state which we will not use inside the View, and lets mutate it.

Remember: In the context of ObservableObject if any property of the state would change, it will emmit View to redraw, lets check what will happen in the case of Observable.

@Observable final class HomeTask {
var title: String
var isCompleted: Bool
var priority: Int = 0

init(title: String, isCompleted: Bool) {
self.title = title
self.isCompleted = isCompleted
}
}

Lets add priority for our tasks, and add possibility to mutate it from the View

struct ContentView: View {
var body: some View {
let _ = Self._printChanges()

VStack {
HStack {
Image(systemName: homeTask.isCompleted ? "checkmark.circle.fill" : "circle")
Text(homeTask.title)
.strikethrough(homeTask.isCompleted)
.foregroundColor(homeTask.isCompleted ? .green : .white)
}
.padding()

Button("Mark as Completed") {
homeTask.isCompleted = true
}
.padding()

Button("Increase Priority") {
homeTask.priority = homeTask.priority + 1
}
.padding()
}
}
}

And lets check how it is working in practice.

Take a close look to the console

You can see the mutation for isCompletedproperty makes View to redraw but it is not doing same for priority property, cause priority is not used anywhere inside the View, and View is not rendering any data from priority to the View, but it actually mutates it.

Its magic, isn’t it ? Just adding one single Macroon our model fixed the most complex and as well straightforward problem of the SwiftUI Observability.

Macro? Yes @Observable is Macro, it looks like a property-wrapper but not its whole new feature. They were introduced in Swift 5.9 as a powerful metaprogramming feature. Swift macros allow developers to generate or transform code at compile time. You can watch about macros to WWDC: Write a Swift Macros

The Observable macro and its expansion

One of the coolest feature of Macros is to expand it in realtime, to check actually what code it adds into your context.

final class HomeTask {
@ObservationTracked
var title: String
@ObservationTracked
var isCompleted: Bool
@ObservationTracked
var priority: Int = 0

init(title: String, isCompleted: Bool) {
self.title = title
self.isCompleted = isCompleted
}

@ObservationIgnored private let _$observationRegistrar = Observation.ObservationRegistrar()

internal nonisolated func access<Member>(
keyPath: KeyPath<HomeTask , Member>
) {
_$observationRegistrar.access(self, keyPath: keyPath)
}

internal nonisolated func withMutation<Member, MutationResult>(
keyPath: KeyPath<HomeTask , Member>,
_ mutation: () throws -> MutationResult
) rethrows -> MutationResult {
try _$observationRegistrar.withMutation(of: self, keyPath: keyPath, mutation)
}
}

extension HomeTask: Observation.Observable {
}

Here is the code which Observable macro generates, lets deep dive into it and check what is happening here.

  1. It adds @ObservationTracked to all stored properties. @ObservationTracked is also a macro that can be expanded further. We'll see later that this macro converts the original stored properties into computed properties. Additionally, for each converted stored property, the @Observable macro adds a new stored property with an underscore prefix but with another macro on of them @ObservationIgnored
  2. It adds content related to ObservationRegistrar, including an _$observationRegistrar instance, as well as two helper methods, access and withMutation. These two methods accept KeyPath of HomeTask and forward this information to the relevant methods of the registrar.
  3. It makes HomeTask conform to the Observation.Observable protocol. This protocol doesn't require any methods, it only serves as a compilation aid.

Lets also expand @ObservationTracked macro on a title

var title: String {
@storageRestrictions(initializes: _title)
init(initialValue) {
_title = initialValue
}
get {
access(keyPath: \.title)
return _title
}
set {
withMutation(keyPath: \.title) {
_title = newValue
}
}
}

Hmm, it becomes interesting. Now lets just copy/paste whole generated code and then we would remove Macro on top of our model and we will just have generated code, which will work as goos as before.

So here is full magic of our Observable Macro.

final class HomeTask {
var title: String {
@storageRestrictions(initializes: _title)
init(initialValue) {
_title = initialValue
}
get {
access(keyPath: \.title)
return _title
}
set {
withMutation(keyPath: \.title) {
_title = newValue
}
}
}

var isCompleted: Bool {
@storageRestrictions(initializes: _isCompleted)
init(initialValue) {
_isCompleted = initialValue
}
get {
access(keyPath: \.isCompleted)
return _isCompleted
}
set {
withMutation(keyPath: \.isCompleted) {
_isCompleted = newValue
}
}
}

var priority: Int = 0 {
@storageRestrictions(initializes: _priority)
init(initialValue) {
_priority = initialValue
}
get {
access(keyPath: \.priority)
return _priority
}
set {
withMutation(keyPath: \.priority) {
_priority = newValue
}
}
}

init(title: String, isCompleted: Bool) {
self.title = title
self.isCompleted = isCompleted
}

@ObservationIgnored private let _$observationRegistrar = Observation.ObservationRegistrar()

@ObservationIgnored private var _title: String
@ObservationIgnored private var _isCompleted: Bool
@ObservationIgnored private var _priority: Int

internal nonisolated func access<Member>(
keyPath: KeyPath<HomeTask , Member>
) {
_$observationRegistrar.access(self, keyPath: keyPath)
}

internal nonisolated func withMutation<Member, MutationResult>(
keyPath: KeyPath<HomeTask , Member>,
_ mutation: () throws -> MutationResult
) rethrows -> MutationResult {
try _$observationRegistrar.withMutation(of: self, keyPath: keyPath, mutation)
}
}

extension HomeTask: Observation.Observable {
}

So what we see here?

  1. init(initialValue) is a new syntax specifically added in Swift 5.9, known as Init Accessors. Since the macro can't rewrite the implementation of the existing HomeTask initializer, it provides a "backdoor" for accessing computed properties in HomeTask.init, allowing us to call this init declaration in the computed properties to initialize the newly generated underlying stored property _title.
  2. @ObservationTracked converts title into a computed property, adding a getter and setter for it. By calling the previously added access and withMutation, it associates property read-write operations with the registrar.

Rough idea of how the Observation framework works in Swift is like that :

When accessing properties on an instance through a getter in View's body, the observation registrar will leave an "access record" for it and register a method that can refresh the View it resides in, when modifying this value through the setter, the registrar retrieves the corresponding method from the record and call it to trigger a refresh.

ObservationRegistrar and withObservationTracking

You may have noticed that the access method in ObservationRegistrar has the following signature:

func access<Subject, Member>(
_ subject: Subject,
keyPath: KeyPath<Subject, Member>
) where Subject : Observable

In this method, we can get the instance of the model type itself and the KeyPath involved in the access. However, relying solely on this, we can't get any information about the caller (in other words, the View). There must be something hidden in between.

The key is a global function in the Observation framework, withObservationTracking

func withObservationTracking<T>(
_ apply: () -> T,
onChange: @autoclosure () -> () -> Void
) -> T

It takes two closures: in the first apply closure, any variables of the Observable instances accessed will be observed, any changes to these properties will trigger the onChange closure once and only once.

For example, “On Changed” is only printed when isCompleted is changed for the first time.

In the above example, there are a few points worth noting:

  1. Since in apply, we only accessed the isCompleted property, onChange wasn't triggered when setting homeTask.title. This property wasn't added to the access tracking.
  2. When we set homeTask.isCompleted = true, onChange was called. However, the isCompleted obtained at this time is still false. onChange occurs during willSet of the property. In other words, we can't get the new value in this closure.
  3. Changing the value of isCompleted again won't trigger onChange again. The related observations were removed the first time it was triggered.

withObservationTracking plays an important bridging role, linking the observation of model properties in SwiftUI's View.body

Considering the fact that the observation is triggered only once, assuming there’s a renderUI method in SwiftUI to re-evaluate body, we can simplify the entire process as a recursive call:

var homeTask: HomeTask
func renderUI() -> some View {
withObservationTracking {
VStack {
Image(systemName: homeTask.isCompleted ? "checkmark.circle.fill" : "circle")
Button("Complete") {
homeTask.isCompleted = true
}
}
} onChange: {
DispatchQueue.main.async { self.renderUI() }
}
}

Of course, in reality, within onChange, SwiftUI only marks the involved views as dirty and then redraws them in the next main runloop. I've simplified this process here only for demonstration purposes.

Implementation details

Apart from the SwiftUI-related parts, the good news is we don’t have to guess about the implementation of the Observation framework. It’s open-sourced as part of the Swift project. You can find all the source code for the framework here. The framework’s implementation is very concise, direct, and clever. Although it’s very similar to our assumptions overall, there are some details worth noting in the specific implementation.

Tracking access

As a global function, withObservationTracking provides a general apply closure without any type information (it has a type () -> T). The global function itself doesn't have a reference to a specific registrar. So, to associate onChange with the registrar, it must rely on a global variable (the only storage space that a global function can access) to temporarily save the association between the registrar (or key path) and the onChange closure.

In the implementation of the Observation framework, this is achieved by using a custom _ThreadLocal struct and storing the access list in the thread storage behind it. Multiple different withObservationTracking calls can track properties on multiple different Observable objects, corresponding to multiple registrars. And all tracking will use the same access list. You can imagine it as a global dictionary, with the ObjectIdentifier of the model object as the key, and the value contains the registrar and accessed KeyPaths on this object. Through this, we can finally find the contents of the onChange that we want to execute:

struct _AccessList {
internal var entries = [ObjectIdentifier : Entry]()
// ...
}

struct Entry {
let context: ObservationRegistrar.Context
var properties: Set<AnyKeyPath>
// ...
}

struct ObservationRegistrar {
internal struct Context {
var lookups = [AnyKeyPath : Set<Int>]()
var observations = [Int : () -> () /* content of onChange */ ]()
// ...
}
}

Thread safety

When establishing an observation relationship (in other words, calling withObservationTracking), the internal implementation of the Observation framework uses a mutex to ensure thread safety. This means that we can use withObservationTracking safely on any thread without worrying about data race issues.

During the triggering of observations (the setter side), no additional thread handling is performed for the invocation of observations. The onChange will be executed on the thread where the first observed property is set. This means that if we want to carry out some thread-safe operations within onChange, we need to be aware of the thread on which the call is taking place.

In SwiftUI, this isn’t a problem, as the re-evaluation of View.body will be "consolidated" and performed on the main thread. However, if we're using withObservationTracking separately outside of SwiftUI and want to refresh the UI within onChange, it's best to perform a check about the current thread.

Performance tips

Compared to the traditional ObservableObject model type that observes the entire instance, using @Observable for property-level observation can naturally reduce the number of times View.body is re-evaluated (because accessing properties on an instance will always be a subset of accessing the instance itself). In @Observable, a simple access to an instance does not trigger re-evaluation, so some former performance "optimization tricks", such as trying to split the View's model into fine-grained parts, may no longer be the best solution.

For instance, when using ObservableObject, if our model type is:

final class Person: ObservableObject {
@Published var name: String
@Published var age: Int

init(name: String, age: Int) {
self.name = name
self.age = age
}
}

We used to prefer doing this, splitting child views containing the smallest piece of data they need:

struct ContentView: View {
@StateObject
private var person = Person(name: "Tom", age: 12)

var body: some View {
NameView(name: person.name)
AgeView(age: person.age)
}
}

struct NameView: View {
let name: String
var body: some View {
Text(name)
}
}

struct AgeView: View {
let age: Int
var body: some View {
Text("\(age)")
}
}

In this way, when person.age changes, only ContentView and AgeView need to be refreshed.

However, after adopting @Observable:

@Observable final class Person {
var name: String
var age: Int

init(name: String, age: Int) {
self.name = name
self.age = age
}
}

It would be better to just pass person directly down to child views:

struct ContentView: View {
private var person = Person(name: "Tom", age: 12)

var body: some View {
PersonNameView(person: person)
PersonAgeView(person: person)
}
}

struct PersonNameView: View {
let person: Person
var body: some View {
Text(person.name)
}
}

struct PersonAgeView: View {
let person: Person
var body: some View {
Text("\(person.age)")
}
}

Issues

Beside lot of good things, we have kinda big problems as well with Observation framework

  1. No natural support for value types

Observation leads us to believe that structs are no longer appropriate for modeling domain types. If we want granular observation for these types and their nestings, then we should convert them to classes and apply the @Observablemacro

Which means that while using@Observable macro, we didn’t pay attention to the fact that we are now spreading reference types all over our applications. And it turns out that the @Observable macro simply does not work on structs. If you try to apply it to a struct you will instantly be greeted with a compiler error letting you know that the macro currently only works with classes.

But technically you can somehow skip that compile error if you will not use direct macro, and just copy/paste all the generated code into your model and then just swap class with struct.

struct HomeTask {
var title: String {
@storageRestrictions(initializes: _title)
init(initialValue) {
_title = initialValue
}
get {
access(keyPath: \.title)
return _title
}
set {
withMutation(keyPath: \.title) {
_title = newValue
}
}
}

var isCompleted: Bool {
@storageRestrictions(initializes: _isCompleted)
init(initialValue) {
_isCompleted = initialValue
}
get {
access(keyPath: \.isCompleted)
return _isCompleted
}
set {
withMutation(keyPath: \.isCompleted) {
_isCompleted = newValue
}
}
}

var priority: Int = 0 {
@storageRestrictions(initializes: _priority)
init(initialValue) {
_priority = initialValue
}
get {
access(keyPath: \.priority)
return _priority
}
set {
withMutation(keyPath: \.priority) {
_priority = newValue
}
}
}

init(title: String, isCompleted: Bool) {
self.title = title
self.isCompleted = isCompleted
}

@ObservationIgnored private let _$observationRegistrar = Observation.ObservationRegistrar()

@ObservationIgnored private var _title: String
@ObservationIgnored private var _isCompleted: Bool
@ObservationIgnored private var _priority: Int

internal nonisolated func access<Member>(
keyPath: KeyPath<HomeTask , Member>
) {
_$observationRegistrar.access(self, keyPath: keyPath)
}

internal nonisolated func withMutation<Member, MutationResult>(
keyPath: KeyPath<HomeTask , Member>,
_ mutation: () throws -> MutationResult
) rethrows -> MutationResult {
try _$observationRegistrar.withMutation(of: self, keyPath: keyPath, mutation)
}
}

extension HomeTask: Observation.Observable {
}

You can check that everything works absolutely same, as it was with reference types, so why does Apple engineers restrict us to use macro only on classes and not on value types ?

The behavior of observation for value types isn’t immediately clear. Value types, unlike reference types, are designed to be freely copied, with each copy being completely independent from the original. This raises several questions: Should copied values share the same observer registrar as the original? Or should each copy get its own separate registrar? Furthermore, if you copy a value, modify it, and then copy it back to the original, should their observer registrars be merged in some way?

var state: State = State()
var copy = state

This variables in Observation context will have lot of problems, cause the state and copy values are secretly sharing the same observation registrar, which contains a reference type deep inside, and therefore mutations made to the state are still going to notify anyone observing the copy value, which is kinda tricky.

This example demonstrates the challenges of applying the Observation framework to structs. Simply using the framework with structs doesn’t produce the desired results. One potential solution is to use copy-on-write semantics. When you apply the Observablemacro to a struct, it embeds a reference type within the struct. This allows the system to check if the reference type is uniquely referenced during mutations. If it isn’t, a copy of the reference type can be created to ensure correct behavior.

Philippe Hausler, the Apple engineer who initially proposed the Observation framework, developed a proof-of-concept for this copy-on-write mechanism. When a struct is mutated, it checks if the struct’s underlying Extent the reference type within the observation registrar is uniquely referenced. If not, it creates a new Extent.

However, it was eventually concluded that this approach wasn’t ideal. While creating a new Extent prevents mutations of the copy from notifying observations of the original state, it also stops observations of the copy itself. Since a new Extentis created, all of its internal state is reset, which undermines the purpose of observation in both the original and copied variables.

2. Observation is only allowed in iOS17 and above

The deployment target required by Observation is iOS 17, which is a tough goal for most apps to reach in the short term. Consequently, developers face a significant dilemma: there are clearly superior and more efficient methods, but they can only be used two or three years from now. Every line of code written in the conventional way during this time will become future technical debt. It’s a rather frustrating situation.

Solution

Solution for backporting issues is perception library from PointFree guys

Swift-perception is library developed by two amazing engineers BrandonStephen

https://github.com/pointfreeco/swift-perception

The Perception library provides tools that mimic @Observable and withObservationTracking in Swift 5.9, but they are backported to work all the way back to iOS 13, macOS 10.15, tvOS 13 and watchOS 6. This means you can start taking advantage of Swift 5.9's observation tools today, even if you can't drop support for older Apple platforms. Using this library's tools works almost exactly as using the official tools, but with one small exception.

You can check their README for more information — using Perception is incredibly straightforward. It seamlessly integrates with TCA, and if you’re using iOS 17 or later, the framework automatically leverages the original Observation. For iOS versions below 17, it switches to Perception.

The team behind PointFree also provided a solution for Observation on value types. While it’s not a standalone package like Perception, their Composable Architecture includes a macro called `@ObservableState`. This macro offers reliable and efficient observation for value types. Personally, I favor value types because they offer numerous advantages over classes. Most of the time, we only need value types. Even SwiftUI encourages the use of structs since views are now structs, unlike the days of UIKit when everything was a class. The `@ObservableState` macro ensures safe and effective observation for value types, which aligns perfectly with Swift’s emphasis on value types.

You can use TCA or check how they implemented ObservableState, cause verything is opensource.

The Composable Architecture

I strongly recommend watching their video series on Observation, which covers the challenges, current state, and future of the framework. These videos professionally explain and test all the technical aspects, including solutions like ObservableState. It’s definitely worth investing in a subscription to PointFree, you’ll gain a lot of valuable insights and knowledge.

References

Thanks for reading and happy hacking, in next episode of this article I will create Swift Observation Macro from Scratch, it will be kinda funny recreational programming session. 👾

]]>
<![CDATA[The foundational elements of scalability in mobile engineering teams]]> https://tgomareli.medium.com/the-foundational-elements-of-scalability-in-mobile-engineering-teams-635b40c214fa?source=rss-e061295e669a------2 https://medium.com/p/635b40c214fa Fri, 26 Apr 2024 12:12:39 GMT 2024-04-26T12:12:39.789Z

Scaling a mobile app involves more than just addressing technical complexities. It’s also critical to consider the evolving needs of the mobile team and to strategize for sustainable expansion. Essential tasks such as enhancing features, broadening localization options, and facilitating more dynamic app delivery are important, but so is the overarching structure of the team.

For effective scaling, it’s imperative that mobile teams strategically organize their structure, rigorously evaluate the scalability of their codebase, and adopt an application architecture that allows engineers to contribute independently without jeopardizing the entire application. These strategic choices are key in helping mobile teams navigate the technical challenges of scaling effectively.

Compact, more dedicated teams

Transitioning from a centralized team with a unified codebase to multiple autonomous teams managing distinct segments is a familiar progression. Initially, mobile teams typically operate collectively, drawing from a single data repository. Over time, as the organization matures, it commonly divides into smaller, more dedicated teams, each tasked with specific functions or components.

Once, I was involved with a team that created a virtual piano application tailored for educational purposes. Initially, our balanced team of six, split evenly between iOS and Android engineers, managed every point of the application — from ensuring user login functionalities to overseeing the lesson content and tooling. As the user base expanded and the demand for additional features escalated, so did our team, doubling in size to twelve engineers. This growth introduced significant challenges. Coordinating updates became increasingly complicated, task allocation grew more dramatically, and even simple daily stand-ups turned into hard discussions. To address these issues, we started organizing into smaller, specialized groups, each dedicated to distinct areas of the application.

Adopting this approach not only simplifies the lives of engineers but also streamlines their focus, as it reduces the need to manage every detail. With a well-structured technical architecture, each engineer can specialize in a specific domain of the application, becoming an expert in their designated area. For instance, within that team, my specialty was managing how the piano parsed note sheets from a JavaScript library and converted them into Swift, and how Core Graphics rendered the piano animations in real-time. This specialization allowed me to concentrate deeply on my segment without the need to master every other aspect of the app.

However, narrowing the focus can have its drawbacks. Smaller, specialized teams need robust cross-team communication to ensure effective collaboration on intersecting features. If my team is solely in charge of the X process, but we also need to integrate the device’s X feature on the app’s some page, we must understand if the team handling that section can accommodate the change, and how their priorities and timelines might differ.

In contrast to a web application, a feature within a mobile app cannot be launched separately from the entirety of the app. Therefore, it’s essential for multiple teams to collaborate closely and for comprehensive regression testing cycles to be conducted.

Maintaining clear documentation of updates in each app release, ensuring that pull requests are open for review by anyone, organizing weekly platform-specific stand-ups (one for iOS and another for Android), and setting up joint design reviews for all client-side engineers are effective strategies to foster ongoing communication among teams while avoiding overwhelming developers with minor details.

Well written, organized, composable and testable code

Discussing the importance of well-structured, testable code can sometimes feel cliché because it’s a well-understood principle among professionals that quality coding practices are crucial for long-term team functionality and sustained feature development. However, it’s alarming how often this principle is deprioritized in favor of immediate results choosing today’s egg over tomorrow’s chicken, so to speak.

Imagine if during the construction of an airplane, the lead manager urged engineers to rush the installation of the air conditioning system just to speed up the first flight and draw in new customers. Such a scenario is unlikely because the risk to human lives is direct and clear. Yet, in software development, we frequently encounter similar pressures from management, pushing for quick fixes and quick features that compromise long-term quality.

What should we do as senior engineers, when faced with such management? The key is to shift the conversation from technical to business impacts. Avoid technical jargons like “bad practice” or “suboptimal patterns,” which may not resonate with managerial concerns. Instead, articulate the business consequences of deploying unmaintainable code. Illustrate how future modifications and maintenance will consume more developer time, thus increasing costs significantly demonstrated by estimating the additional financial burden based on developer salaries.

By framing the discussion in terms of business outcomes highlighting the cost implications and potential delays, management can better appreciate why investing in high-quality, testable code is not just a technical necessity but a strategic business decision. This approach makes it easier to advocate for best practices that ensure sustainable development and long-term gains.

Modular, orchestrated and reasonable architecture

Good Architecture is key when aiming for a testable app with various components and modules. Building such an architecture requires meticulous planning and ongoing improvement. Without regular updates and adaptations, any architecture will eventually become outdated. It’s important to recognize that there is no one-size-fits-all solution in architecture — whether it’s Clean, MVP, MVC, VIPER, MVVM, MVU, or any other model.

The choice of architecture depends on several factors: the team’s composition, the developers’ level of expertise, business needs, and scalability requirements. These elements dictate the architectural framework that will best suit a project. After understanding these needs and assessing your team’s capabilities, you can develop a flexible architecture tailored to your specific circumstances.

Personally, I find value in adapting aspects from various architectural models depending on the project’s requirements. For instance, I am currently a proponent of the Composable Architecture by PointFree, which, while powerful, demands a higher level of understanding, a steep learning curve, and a fundamentally different approach to programming. It may be well-suited to some environments but not universally applicable.

When thinking about architecture, avoid adopting a one-size-fits-all approach as seen in typical project templates. Every team adapts architectural principles in unique ways that best fit their context. The most crucial attributes of any effective architecture include modularity, composability, robust tooling, and reusability. Aim to enhance the developer experience by avoiding redundant code — instead, provide modules and tools that streamline their workflows.

Structure your architecture with composable units to allow for easy replacement and update of components. Implement dependency inversion with type erasure to ensure flexibility in how components and tools interact. It’s also vital to develop accessible testing tools and frameworks, particularly to aid less experienced developers who may struggle with testing methodologies.

As your team and codebase grow, so too will your build times. This increase affects not just the developers who build the code frequently each day but also extends to CI/CD pipelines processing every pull request and QA testers awaiting new builds. An increase in build times can subtly shift your team into a waiting mode, which may significantly reduce overall performance. Therefore, it’s crucial to focus on optimizing and reducing these times.

A practical approach is to adopt horizontal modularization instead of vertical modularization, as this can help manage dependencies more efficiently and reduce build times. For a deeper understanding of these concepts, consider reading this insightful article on modularization in iOS using Swift Package Manager: Modularisation in iOS.

It’s also essential to evaluate your current development tools. If you’re using xcodeproj or workspace to manage multiple frameworks or modules, there might be more efficient methodologies available. Tools like Tuist, Xcodegen, and Bazel offer alternative ways to structure and build projects that might better suit your needs.

Further, minimize the use of extensive type inference and avoid dynamic dispatch where possible, as these can significantly slow down your build processes. Implementing tools that can measure and monitor build times is also crucial. Regular monitoring allows you to identify and address inefficiencies proactively, keeping your development process as agile and effective as possible.

Lastly, and most importantly, guard against overengineering. In the current landscape, there is a tendency to complicate solutions unnecessarily. Strive for simplicity in your architectural decisions, as it often leads to more elegant and manageable solutions. This philosophy is eloquently discussed by Rich Hickey in his 2011 talk, “Simple Made Easy,” which I highly recommend for deeper insights into simplicity in software engineering. You can watch it here: Rich Hickey’s Talk on Simplicity. This talk is one of the most impactful and insightful technical discussions I have encountered.

Ongoing development and refinement

Even if you’re currently working in a legacy codebase, the journey towards implementing new ideas and methodologies within your team is entirely possible. By gradually introducing these concepts and practices, both you and your colleagues will begin to witness significant improvements. No one enjoys working under stressful conditions, and there’s everyones desire for simplicity and ease in our daily work routines.

Establishing a flexible and scalable team structure, codebase, and architecture is a very challenging task. Especially when dealing with a monolithic mobile app, this demands extra attention and care. Achieving an ideal architecture for large teams is a long process, as it’s an ongoing journey rather than a destination. There will always be room for improvement, whether it’s refining processes or addressing unforeseen edge cases.

However, if each engineer is dedicated to delivering quality code, adhering to architectural guidelines, and feeling a sense of ownership over both technical and non-technical improvements, the evolution of your team can become your collective culture. This commitment enables teams to scale efficiently, to scale as quickly and efficiently as the apps they build.

Thanks for reading, and happy coding.

]]>
<![CDATA[Transforming Doubt into Drive: The Engineer’s Journey Through Imposter Syndrome]]> https://tgomareli.medium.com/transforming-doubt-into-drive-the-engineers-journey-through-imposter-syndrome-b899bceaf1d4?source=rss-e061295e669a------2 https://medium.com/p/b899bceaf1d4 Sun, 03 Dec 2023 12:21:58 GMT 2023-12-03T12:21:58.803Z

What is Imposter Syndrome and why do we feel It?

Have you ever felt like you’re masquerading as something you’re not, secretly afraid of being “found out” as not as competent or talented as people think? That’s Imposter Syndrome, a psychological phenomenon where individuals doubt their accomplishments and have a persistent fear of being exposed as a “fraud.”

But why do we feel it? Interestingly, it often stems from a good place — our desire to do well and our commitment to high standards. In the fast-evolving field of technology, where each day presents a new framework, tool, or language to master, it’s easy to feel like we’re not keeping up. The more we know, the more acutely aware we become of what we don’t know. This paradox is particularly common in fields like ours, where innovation is rapid and the pressure to be on the cutting edge is intense.

Ironically, it’s often the brightest and most skilled individuals who feel imposter syndrome the most intensely. It’s as if our brain discounts the evidence of our hard work and achievements, focusing instead on an illusory lack of skill. This misalignment between perception and reality can be attributed to a cognitive bias known as the Dunning-Kruger effect, where the highly competent are more likely to underestimate their abilities while the less competent overestimate theirs.

Understanding the roots of imposter syndrome is the first step in overcoming it. By acknowledging that it’s a shared and natural experience, especially among those pushing their boundaries, we can start to dismantle its power over us, paragraph by paragraph, line by line, just like we would debug a complex piece of code.

Why Do Engineers Doubt Themselves?

As engineers, we often find ourselves questioning our abilities. It’s like a shadow that quietly trails behind us, even after years of solving complex problems and writing code. But let’s break down why this happens.

The tech world moves at lightning speed. What we learned as the newest technique can quickly feel outdated. There’s always a new language or tool to master, and it feels like we’re forever playing catch-up with the ever-shifting finish line.

On social media and professional networking sites, we see the highlights: promotions, successful projects, and breakthroughs. But the setbacks and the stumbles? Those don’t make it to the newsfeed. It can make us feel like we’re the only ones struggling, which isn’t the truth.

Our job is to look for what’s not working — to find solutions and solve problems. It’s no surprise that this habit of searching for imperfections can lead us to be overly critical of our expertise.

And let’s talk about productivity. It’s unrealistic to expect that we’ll be 100% on our game all the time. Our energy levels ebb and flow. Sometimes, the work isn’t all that exciting, or we might just be feeling low on motivation. It’s often in these moments that we start to wonder if our engineering skills are fading or if we were ever that good to begin with.

But here’s the twist: If you never doubted yourself, it would mean you’re comfortable. And in our field, too much comfort can lead to stagnation. Those doubts? They’re a sign that you’re pushing the envelope, that you care enough to question and strive for better. Doubt isn’t the enemy, it’s a natural part of the creative and technical process that pushes us to refine our skills continually.

So, when the shadow of doubt looms, remember it’s part of being yourself. It’s not a question mark over your skills but a stepping stone on the path of growth.

The False Security of the Comfort Zone and the Reality of Growth

Think about the times when everything at work just clicks. You’re solving problems you’ve handled a hundred times before. You feel confident, and sure of your skills — you’re in your comfort zone. But if you’re always this sure of yourself, you might be missing out on growing. It’s like staying in the shallow end of the pool when you could be swimming in deeper waters.

If you’re not feeling a bit unsure or like you’re out of your depth sometimes, it could mean you’re not learning anything new. That feeling of being an ‘imposter’ might be a good sign. It usually shows up when you’re facing something tough, learning a new skill, or taking on a challenge that’s bigger than what you’re used to.

The Dunning-Kruger effect is when people who know just a little think they know a lot. When we start something new, we don’t know enough to see how much we still have to learn, so we feel pretty good about our skills. But as we learn more, we start to see just how much we don’t know. That’s when we feel like imposters — not because we’re not good, but because we’re better than we were, and now we see the whole mountain of stuff we still need to learn.

So if you never feel like you’re not quite good enough, you might just be playing it safe, sticking to what you know and not pushing yourself. Feeling like an imposter means you’re stepping out, and trying new things, and that’s where real growth happens. It’s okay to be in the comfort zone sometimes, but don’t stay there too long, or you’ll miss all the exciting stuff that happens when you stretch your limits.

Embracing Failure as a Learning Tool

In our field and not only, the fear of failure is a significant contributor to Imposter Syndrome and doubts. Many of us, especially seniors and leads, equate failure with a lack of ability or knowledge. This fear often manifests as a voice in our heads, telling us that one mistake or misstep will unveil us as frauds. But this perspective overlooks a crucial truth: failure is an integral part of learning and growth.

Thomas Edison famously said, “I have not failed. I’ve just found 10,000 ways that won’t work.” His mindset transforms failure from a negative endpoint into a vital step in the process of innovation. In our field, every problem, every struggle, and every piece of feedback is a golden opportunity for improvement. Instead of viewing these instances as evidence of our inadequacies, we can reframe them as signposts guiding us toward our goals.

Consider the stories of tech luminaries like Elon Musk and Steve Jobs. Musk’s SpaceX saw its first three rocket launches end in failure. Instead of giving up, Musk used these experiences to refine his approach, leading to the successful launch of the Falcon 1 on the fourth attempt, and eventually, revolutionizing space travel. Similarly, Steve Jobs, ousted from Apple, the company he co-founded, used this period to develop skills and ideas that he later brought back to Apple, leading to some of its most innovative products.

These stories show us something important: the road to great success is often filled with failures. What matters is how we react to these failures. Instead of getting stuck on our mistakes, we should learn from them. This way, we turn our failures into steps that help us move forward and succeed.

Just as a craftsman uses different tools to shape and perfect their creations, we can wield failure as a tool to sculpt our skills, knowledge, and resilience. Each failure carves out the rough edges, revealing a more refined version of our professional selves.

The Role of Leadership, Mentorship, Team and 1-to-1 Interactions

In a team, each member has their way of working, and how well they do can change from time to time. Some might do well for a while, then have a period when they’re not as effective. This isn’t about being better or worse, it’s about finding what fits best for them in the team. Life happens, and personal challenges can affect their work, too. This is often when people start feeling like they’re not up to the mark. This can be especially tough for those who are less experienced and can lead to a lack of motivation. That’s why it’s so important for team leaders and mentors to understand and show empathy. By helping each person find their place and feel confident, regardless of what’s going on in their personal lives, everyone can thrive.

There are two types of leaders we face most of the time

The Disconnected Leader

Consider a team lead who maintains a distance, focusing mainly on deadlines and outputs. In this team, a skilled yet introverted member starts feeling less motivated and productive, battling feelings of inadequacy without a way to express these doubts. The lead’s lack of engagement deepens the team members’ sense of isolation and Imposter Syndrome, negatively impacting performance and job satisfaction. This scenario highlights how a leader’s lack of involvement can exacerbate self-doubt and detachment.

The Proactive Leader

In a contrasting scenario, a team lead is deeply engaged with the team. Regular 1-to-1 meetings are held, creating an environment of open communication and feedback. A team member facing Imposter Syndrome finds these interactions invaluable. The lead monitors progress, acknowledges efforts, provides feedback, and encourages setting and celebrating achievable goals. This approach helps the team member combat self-doubt, enhancing productivity and morale, and fostering a sense of recognition and belonging. This example underscores the positive influence a leader can have through active support and guidance.

Now, ask yourself: In which leader’s team would you prefer to be? And which one will achieve better results with their team?

I think the answer is pretty straightforward.

Balancing Self-Criticism with Self-Compassion

Self-criticism is often seen as a tool for growth and improvement. However, there’s a fine line between constructive self-criticism and detrimental self-doubt. While the former can propel us towards excellence, the latter can spiral into a counterproductive mindset, impeding both personal and professional growth. Striking a balance between these two is crucial.

Self-compassion plays a pivotal role in this balance. It’s about treating yourself with the same kindness and understanding that you would offer to a colleague or friend in a similar situation. Recognizing that mistakes and setbacks are part of the learning process is essential in fostering a healthy attitude toward self-improvement.

Tips for Practicing Self-Compassion

  1. Acknowledge and Accept Your Feelings: When you’re facing a challenge or a setback, acknowledge your feelings without judgment. Accept that feeling frustrated or doubtful is a natural part of tackling complex tasks.
  2. Reflect on Past Successes: Remind yourself of your past achievements and the obstacles you’ve overcome. This can provide a more balanced perspective of your abilities and potential.
  3. Set Realistic Goals: Set achievable goals for your work. Celebrate the small victories along the way, as they are the stepping stones to larger successes.
  4. Seek Feedback Constructively: Instead of fearing feedback, embrace it as a tool for learning. Constructive feedback, when approached with an open mind, can be a valuable source of insight and growth.
  5. Practice Mindfulness: Mindfulness can help you stay grounded and focused, reducing the tendency to over-criticize yourself. Techniques like meditation or even a few minutes of quiet reflection can be beneficial.
  6. Connect with Peers: Sharing your experiences with peers can provide perspective. Often, you’ll find that others have faced similar challenges, reminding you that you’re not alone in your journey.

Practicing self-compassion doesn’t mean lowering your standards. It’s about approaching your work with a mindset that values growth and learning, without the paralyzing fear of making mistakes. Balancing self-criticism with self-compassion allows engineers to strive for excellence while nurturing their well-being and confidence.

Some kind of Practical Strategies

Have you ever tried working on your projects after a hard day’s work? Or felt burnt out juggling a full-time job, freelancing, and your projects? I certainly have. And I’ve found a method that helps me. It’s important to note that everyone’s mind works differently, so what works for me might not be the exact solution for you. However, I can share my key strategy for overcoming these challenges.

Whenever I find that my work isn’t bringing me joy, I turn to pet projects. I keep a list of open-source ideas and dive into whichever one grabs my interest. I often choose projects in domains where I have no prior knowledge. For instance, my latest open-source project was in Rust, a language I knew nothing about beforehand. I made the project so intriguing that I became completely absorbed in it. I spend my time reading and understanding the problem, searching for solutions in that language or domain. My mind gets so occupied with the idea that I don’t have time to think about being tired after work. The moment I’m free, I plunge into this captivating maze of research, reading, coding, and then more researching and reading. This process of incremental programming sparks new ideas and concepts. While working on one project, I often come up with ideas for future open-source projects, keeping my list ever-growing.

After some time, this approach makes me feel fulfilled. I feel like a competent and quality engineer who has achieved something significant on my own. I understand the problems and the technology, and I feel worthy of being called an engineer because I’ve made it, and I’m proud of my work. This strategy has been a practical solution for me, helping me navigate through numerous days filled with doubt and Imposter Syndrome, beyond just managing my thoughts and mindset.”

I sincerely hope this article offers some guidance and support. You might be feeling confident in your abilities right now, but should you ever encounter moments of self-doubt or Imposter Syndrome, I hope these insights will be a beacon of encouragement for you. Knowing that my experiences and strategies could make a positive impact on your journey would bring me immense joy and pride.

Thank you for taking the time to read this. Take care and continue to nurture your incredible potential.

]]>
<![CDATA[Creating Custom, High-Performance Collection Types with Swift]]> https://tgomareli.medium.com/creating-custom-high-performance-collection-types-with-swift-1217b83a3fcc?source=rss-e061295e669a------2 https://medium.com/p/1217b83a3fcc Mon, 10 Apr 2023 06:36:29 GMT 2023-04-10T06:36:29.426Z

Intro

Because Swift is a general-purpose, powerful, and flexible language, Swift is utilized in almost every element of Apple development as well as beyond the ecosystem. Even while the standard library offers a range of crucial collection types, such as arrays, dictionaries, and sets, developers often desire more specialized and effective data structures for specific usage circumstances. We will look at how to create distinctive, high-performance collection types in Swift by examining the core concepts, design decisions, and implementation details.

Understanding Collection Protocols in Swift

You may have noticed that Swift’s built-in collection types, such as arrays, dictionaries, and sets, have a lot of similar characteristics and methods if you’ve ever used them. Swift uses collection protocols in its protocol-oriented architecture to provide this consistent behavior.

Before we can begin to create strong collections using collection protocols, we need to first have an understanding of Collection Protocols in Swift, including their hierarchy and the different degrees of abstraction they offer.

Swift offers a number of protocols that, collectively, describe the operations that are typical of collection types and their attributes. By accepting these protocols, we are able to take advantage of Swift’s robust features and guarantee that our custom collections are compatible with the functions provided by the standard library.

  • Sequence: The base protocol for all types that can be iterated over using a for-in loop. It provides basic operations like map, filter, and reduce.
  • Collection: Defines the core requirements for any collection, such as indexing, subscripting, and iteration.
  • MutableCollection: Conforms to Collection and allows modifying the elements of the collection.
  • BidirectionalCollection: Conforms to Collection and enables reverse traversal of the collection.
  • RandomAccessCollection: Conforms to BidirectionalCollection and allows constant-time indexing and slicing.

Everything starts from Sequence !

Let’s pretend a generic CustomArray exists and see how it might be used to create common collection techniques.

To create a custom collection type that can be iterated using a for-in loop, you must conform to the Sequence protocol. This requires implementing a single method, makeIterator(), which returns an iterator object that conforms to the IteratorProtocol.

struct CustomArray<Element>: Sequence {
private var storage: [Element] = []

func makeIterator() -> CustomArrayIterator<Element> {
return CustomArrayIterator(storage: storage)
}
}

struct CustomArrayIterator<Element>: IteratorProtocol {
private let storage: [Element]
private var currentIndex = 0

mutating func next() -> Element? {
guard currentIndex < storage.count else { return nil }
let element = storage[currentIndex]
currentIndex += 1
return element
}
}

The primary requirement of the Sequence the protocol is to provide an iterator, which is an instance of a type conforming to the IteratorProtocol. The iterator’s job is to supply a way to go sequentially through the sequence’s elements in a linear, sequential manner. The IteratorProtocol requires a next() method, which returns the next element in the sequence, or nil if there are no more elements to iterate over.

But, what is Iterator? Or why does it exists?

In computer science, an iterator is a design pattern that provides a way to access the elements of an aggregate object (a collection or container) sequentially without exposing its underlying representation. Iterators are a crucial aspect of many data structures, as they allow developers to process the elements of a collection in a uniform and consistent manner.

The iterator pattern has several advantages:

  1. Abstraction: The iterator abstracts the process of iterating over a collection, allowing the user to access elements without needing to know the internal details of the data structure. This makes it easier to work with different types of collections, as the same interface can be used to traverse them.
  2. Encapsulation: By providing a specific interface for traversing a collection, the iterator pattern helps to encapsulate the internal structure of the collection. This ensures that the user cannot directly modify the collection or access its elements in an unintended manner, which promotes data integrity and reliability.
  3. Flexibility: The iterator pattern allows developers to define custom traversal strategies for different collections. For example, a tree structure may require an in-order, pre-order, or post-order traversal, depending on the desired outcome. Using an iterator, developers can implement these traversal strategies without modifying the underlying data structure.

In the context of Swift’s Sequence protocol, the iterator plays a crucial role in enabling iteration over elements using a for-in loop, and other usefull benefits.

Sequence the protocol is an essential component of Swift’s collection framework. It offers a robust and standardized user interface for interacting with collections of elements, which makes it an indispensable component. By accepting this protocol, custom types will be able to take part in Swift’s extensive ecosystem of collection-based operations, which will make it much simpler to process and manipulate data in a wide variety of settings.

We still haven’t finished covering Collection, MutableCollection, BidirectionalCollection, and RandomAccessCollection, but I think you have a good idea of how Sequence, Iterator and Swift collections work at their core.

However, we will address them as we build our high-performance Deque from the ground up.

Designing a Custom Collection Type: Deque

Let’s design a double-ended queue, sometimes known as a deque, as a way to illustrate the steps involved in developing a collection type. There are certain circumstances in which the performance of a deque is superior to that of an array. This is because a deque is a linear data structure that permits the insertion and removal of elements from both the front and the back.

Here is Deque Structure

struct Deque<Element> {
private var storage: [Element] = []
}

We must conform with the MutableCollection and BidirectionalCollection protocols in order to make our deque a fully-functional collection type. In order to do this, we must write all necessary implementation, including the necessary properties and methods.

Conforming to Collection

extension Deque: Collection {
typealias Index = Int

var startIndex: Index {
return storage.startIndex
}

var endIndex: Index {
return storage.endIndex
}

subscript(index: Index) -> Element {
return storage[index]
}

func index(after i: Index) -> Index {
return storage.index(after: i)
}
}

Here is the list of things what we’ve done here:

First, we define a struct called Deque with a generic Element type. This allows our deque to hold elements of any type. We create a private storage property, which is an array of Element values. This is where our deque's elements will be stored. The storage is marked private to encapsulate the underlying data structure and prevent direct manipulation from outside the Deque.

We define an associated type Index and set it to Int. This tells the compiler that our deque's indices will be integer values.

We implement two computed properties, startIndex and endIndex, which return the storage's startIndex and endIndex, respectively. These properties define the range of valid indices for our deque.

We implement a subscript that takes an Index as a parameter and returns the Element at that index in the storage array. This allows us to access elements in the deque using subscript notation (e.g., deque[2]).

We implement a method called index(after:) that takes an Index as a parameter and returns the next index in the deque. This is required by the Collection protocol to enable iteration over the deque's elements.

We have conformed Collection protocol successfully, but don’t forget that our Data Structure need to be Mutable and also Bidirectional, that is whole idea of Duque structure, so we still need to conform two protocols they areMutableCollection and BidirectionalCollection

extension Deque: MutableCollection, BidirectionalCollection {
subscript(index: Index) -> Element {
get {
return storage[index]
}
set {
storage[index] = newValue
}
}

func index(before i: Index) -> Index {
return storage.index(before: i)
}
}

These protocols will enhance the functionality of our Deque by allowing for in-place modification of its elements and enabling efficient reverse traversal.

We create an extension for Deque and declare conformance to both MutableCollection and BidirectionalCollection. Since our Deque already conforms to the Collection protocol, we only need to add a few more methods and properties to satisfy the requirements of these new protocols.

We update the existing subscript(index: Index) -> Element to include a get and a set block:

get: This block is the same as the previous implementation, and it returns the element at the given index in the storage array.

set: This block allows us to modify the element at the given index in the storage array. The newValue parameter represents the new value that will replace the existing element at the specified index. This set block is required by the MutableCollection protocol, which enables in-place modification of elements in the deque.

We implement a new method called index(before i: Index) -> Index

func index(before i: Index) -> Index: This method takes an index as a parameter and returns the index immediately preceding it in the deque. This method is required by the BidirectionalCollection protocol, which allows for efficient reverse traversal of the deque's elements.

All the other protocol implementations was required to fill our data structure in Swift collections ecosystem, but for now we need specific implementations which deque need to be fully functional circular queue.

extension Deque {
mutating func prepend(newElement: Element) {
storage.insert(newElement, at: startIndex)
}

mutating func append(newElement: Element) {
storage.insert(newElement, at: endIndex)
}

@discardableResult
mutating func removeFirst() -> Element? {
return storage.isEmpty ? nil : storage.removeFirst()
}

@discardableResult
mutating func removeLast() -> Element? {
return storage.isEmpty ? nil : storage.removeLast()
}
}

prepend method takes an element and inserts it at the beginning of the deque. We use the insert(_:at:) method of the storage array to achieve this. Since this method modifies the storage array, the function is marked as mutating.

append takes an element and inserts it at the end of the deque. Similar to the prepend method, we use the insert(_:at:) method of the storage array. The function is also marked as mutating because it modifies the storage array.

removeFirst method removes and returns the first element in the deque. The @discardableResult attribute indicates that the return value can be ignored if it's not needed. The method first checks if the storage array is empty. If it is, the method returns nil. Otherwise, it removes and returns the first element using the removeFirst() method of the storage array. Since this method modifies the storage array, as above example it's also marked as mutating.

removeLast method removes and returns the last element in the deque. The @discardableResult attribute is used for the same reason as before. The method checks if the storage array is empty, returning nil if so, and otherwise removes and returns the last element using the removeLast() method of the storage array. As with the other methods, this one is also marked as mutating.

Optimisation

A queue built on an array has the drawback that while adding new items to the back of the queue is quick O(1), taking items out of the front of the queue takes time O(n). Removing takes time because the remaining array elements need to be moved around in memory.

Using a circular or ring buffer to implement a queue is more effective. There is no need to ever remove anything from this array because it conceptually goes all the way back to the beginning. Every operation is an O(1).

But first of all we need to answer to this question > What is circular buffer ?

A fixed-size data structure called a circular buffer, also referred to as a ring buffer or cyclic buffer, treats its memory as if it were circular. In situations where there is a producer and a consumer and the data is being processed or consumed at a different rate, it is used to store a small number of elements.
A circular buffer’s main benefit is that it effectively manages circumstances where the buffer might overflow. The oldest element is automatically overwritten when the buffer is full and a new element is added.
One pointer is used for reading (the “head”) and the other is used for writing in a circular buffer. (the “tail”). The write pointer advances as new data is added. The read pointer moves forward when data is consumed or read. Both pointers wrap around to the beginning of the buffer when they reach the end, producing the “circular” effect.

public struct CircularBuffer<Element> {
var storage: [Element?]

var capacity: Int
var head: Int
var tail: Int

public init(capacity: Int) {
self.capacity = capacity
self.storage = Array<Element?>(repeating: nil, count: capacity)
self.head = 0
self.tail = 0
}

private func increment(_ index: Int) -> Int {
return (index + 1) % storage.count
}

private func decrement(_ index: Int) -> Int {
return (index - 1 + storage.count) % storage.count
}

public mutating func prepend(_ element: Element) {
if capacity == 0 {
resize()
}

head = decrement(head)
storage[head] = element
if head == tail {
resize()
}
}

public mutating func append(_ element: Element) {
if capacity == 0 {
resize()
}

storage[tail] = element
tail = increment(tail)
if head == tail {
resize()
}
}

@discardableResult
public mutating func removeFirst() -> Element? {
if storage.count == 0 {
return nil
}

guard let first = storage[head] else {
return nil
}
storage[head] = nil
head = increment(head)
return first
}

@discardableResult
public mutating func removeLast() -> Element? {
if storage.count == 0 {
return nil
}

guard let last = storage[decrement(tail)] else { return nil }
tail = decrement(tail)
storage[tail] = nil
return last
}

private mutating func resize() {
let newCapacity = max(storage.count * 2, 1)
var newStorage = Array<Element?>(repeating: nil, count: newCapacity)
var index = head

for i in 0..<capacity {
newStorage[i] = storage[index]
index = increment(index)
}

storage = newStorage
capacity = newCapacity
head = 0
tail = capacity / 2
}
}

storage: An array of optional elements that represents the underlying storage of the buffer.

capacity: The current capacity of the buffer. When the buffer is full, the capacity will be increased to accommodate new elements.

head: An index pointing to the first element in the buffer.

tail: An index pointing to the next available slot for inserting a new element in the buffer.

init(capacity: Int): The initializer takes an initial capacity as a parameter and initializes the storage array with the specified capacity. It also sets the head and tail indices to 0.

The following private helper methods are used for index manipulation

increment(_ index: Int) -> Int: This method increments the given index and wraps it around the storage count if necessary.

decrement(_ index: Int) -> Int: This method decrements the given index and wraps it around the storage count if necessary.

Public methods for modifying the buffer

prepend(_ element: Element): Adds an element to the beginning of the buffer. If the capacity is 0, it resizes the buffer before adding the element. If the buffer is full, it resizes the buffer to accommodate the new element.

append(_ element: Element): Adds an element to the end of the buffer. If the capacity is 0, it resizes the buffer before adding the element. If the buffer is full, it resizes the buffer to accommodate the new element.

removeFirst() -> Element?: Removes and returns the first element in the buffer. Returns nil if the buffer is empty.

removeLast() -> Element?: Removes and returns the last element in the buffer. Returns nil if the buffer is empty.

The resize() private method is responsible for resizing the buffer. It doubles the current capacity (or sets it to 1 if the current capacity is 0), creates a new storage array with the updated capacity, and copies the existing elements from the old storage to the new storage. After resizing, it resets the head index to 0 and the tail index to half of the new capacity.

Now, we need to modify the Deque structure to use the CircularBuffer instead of a regular array. As well lets make possibility for our initialisation to get already predefined array and convert it to Deque.

struct Deque<Element> {
private var storage: CircularBuffer<Element>

public init(capacity: Int) {
storage = CircularBuffer(capacity: capacity)
}

public init(elements: [Element]) {
let requiredCapacity = elements.count
self.init(capacity: requiredCapacity)

for element in elements {
self.append(element)
}
}
}

Next, we will update the implementation of Deque operations to leverage the circular buffer's efficient indexing and storage management

extension Deque: MutableCollection, BidirectionalCollection {
public subscript(position: Int) -> Element {
get {
precondition(position >= startIndex && position < endIndex, "Index out of range")
return storage.storage[position % storage.capacity]!
}
set {
precondition(position >= startIndex && position < endIndex, "Index out of range")
storage.storage[position % storage.capacity] = newValue
}
}

public func index(before i: Index) -> Index {
precondition(i > startIndex, "Index out of range")
return (i - 1 + storage.capacity) % storage.capacity
}
}
extension Deque: Collection {
public typealias Index = Int

public var startIndex: Index {
return storage.head
}

public var endIndex: Index {
return storage.tail
}

public func index(after i: Index) -> Index {
precondition(i < endIndex, "Index out of range")
return (i + 1) % storage.capacity
}
}
extension Deque {
public mutating func prepend(_ element: Element) {
storage.prepend(element)
}

public mutating func append(_ element: Element) {
storage.append(element)
}

@discardableResult
public mutating func removeFirst() -> Element? {
return storage.removeFirst()
}

@discardableResult
public mutating func removeLast() -> Element? {
return storage.removeLast()
}
}

Performance and Functionality tests

After completing the fundamental implementations for our Deque data structure, it should now function efficiently and effectively. However, to ensure its robustness we will create some stress tests to assess the performance under various conditions, and also let’s write some tests to validate its correctness of functionality.

When testing the Deque implementation, we'll want to make sure to cover a wide range of scenarios to ensure that all functionalities are working correctly.

  1. Initialisation: Test that the deque is created with the correct initial state.
  2. Appending: Test appending elements to the deque and verify the correct order.
  3. Prepending: Test prepending elements to the deque and verify the correct order.
  4. Removing first: Test removing elements from the front of the deque and verify that the deque behaves as expected.
  5. Removing last: Test removing elements from the end of the deque and verify that the deque behaves as expected.
  6. Indexing: Test accessing elements by index and ensure that the deque returns the correct values.
  7. Mutating elements: Test modifying elements in the deque by index and verify that the changes are correctly applied.
  8. Bidirectional indexing: Test moving indices forward and backward using index(after:) and index(before:) methods.
  9. Collection conformance: Test iterating over the deque using a for loop or other collection methods and ensure that elements are in the correct order.
  10. Empty deque behavior: Test edge cases where the deque is empty, and ensure that the implementation handles them correctly (e.g., removing elements from an empty deque should return nil).
  11. Deque resizing: Test that the deque resizes correctly when needed, preserving the correct order of elements and maintaining the correct indices.
import XCTest
import Foundation
@testable import Deque

final class DequeTests: XCTestCase {
func testInitialization() {
let deque = Deque<Int>(capacity: 0)
XCTAssertTrue(deque.isEmpty)
}

func testAppending() {
var deque = Deque<Int>(capacity: 3)
deque.append(1)
deque.append(2)
deque.append(3)
XCTAssertEqual(Array(deque), [1, 2, 3])
}

func testPrepending() {
var deque = Deque<Int>(capacity: 3)
deque.prepend(1)
deque.prepend(2)
deque.prepend(3)
XCTAssertEqual(Array(deque), [3, 2, 1])
}

func testRemovingFirst() {
var deque = Deque<Int>(elements: [1, 2, 3])
XCTAssertEqual(deque.removeFirst(), 1)
XCTAssertEqual(Array(deque), [2, 3])
}

func testRemovingLast() {
var deque = Deque<Int>(elements: [1, 2, 3])
XCTAssertEqual(deque.removeLast(), 3)
XCTAssertEqual(Array(deque), [1, 2])
}

func testIndexing() {
let deque = Deque<Int>(elements: [1, 2, 3])
XCTAssertEqual(deque[1], 2)
}

func testMutatingElements() {
var deque = Deque<Int>(elements: [1, 2, 3])
deque[1] = 4
XCTAssertEqual(Array(deque), [1, 4, 3])
}

func testBidirectionalIndexing() {
let deque = Deque<Int>(elements: [1, 2, 3])
let index = deque.index(after: 0)
XCTAssertEqual(index, 1)
let beforeIndex = deque.index(before: index)
XCTAssertEqual(beforeIndex, 0)
}

func testCollectionConformance() {
let deque = Deque<Int>(elements: [1, 2, 3])
XCTAssertEqual(Array(deque), [1, 2, 3])
}

func testEmptyDequeBehavior() {
var deque = Deque<Int>(capacity: 0)
XCTAssertNil(deque.removeFirst())
XCTAssertNil(deque.removeLast())
}

func testDequeResizing() {
var deque = Deque<Int>(capacity: 3)
deque.append(1)
deque.append(2)
deque.append(3)
deque.append(4) // This should trigger a resize
XCTAssertEqual(Array(deque), [1, 2, 3, 4])
}

func testStressTest() {
var deque = Deque<Int>(capacity: 100000)
let largeNumber = 100_000

for i in 1...largeNumber {
deque.append(i)
}

XCTAssertEqual(deque.count, largeNumber)

for i in 1...largeNumber {
XCTAssertEqual(deque.removeFirst(), i)
}
}
}

And woooha, everything works perfectly.

Sincere to say, I discovered that the previously implemented Deque needed some changes while I was writing unit tests. This demonstrates the value of creating tests for one’s own implementations because they make it possible to find little details that might have been missed during the initial implementation stage. Writing tests ultimately aids in enhancing and perfecting the overall solution.

And lastly lets check performance of our Deque, and how it will handle to append million elements when capacity is 0 and it needs constant resizing.

import Foundation
import XCTest
@testable import Deque

class DequePerformanceTests: XCTestCase {
func testAppendPerformance() {
let largeArray = Array(0..<1000000)
measure {
var deque = Deque<Int>()
for element in largeArray {
deque.append(element)
}
}
}

func testPrependPerformance() {
let largeArray = Array(0..<1000000)
measure {
var deque = Deque<Int>()
for element in largeArray {
deque.prepend(element)
}
}
}

func testRemoveFirstPerformance() {
var deque = Deque<Int>()
for i in 0..<1000000 {
deque.append(i)
}
measure {
while !deque.isEmpty {
_ = deque.removeFirst()
}
}
}

func testRemoveLastPerformance() {
var deque = Deque<Int>()
for i in 0..<1000000 {
deque.append(i)
}
measure {
while !deque.isEmpty {
_ = deque.removeLast()
}
}
}
}
testAppendPerformance for million elements when starting with zero capacity
testPrependPerformance for million elements when starting with zero capacity
removeFirst
removeLast

We can see that our circular buffer struggles with tasks like appending millions of elements, and although it could be optimised even further by creating lazy resizing and custom indexes to avoid lot of bound checkings, also we can make thresholding capacity, or periodically trim down the array.

But for this situation, I believe it is sufficient. Because creating a Deque with predefined capacity will help to have predefined allocated elements in storage and will prevent the need for additional resizing, which in this example is the most labor-intensive and not cheap operation.

As part of our research into Swift’s high-performance custom collection types, we built a Deque with a circular buffer that achieves O(1) operations. Check out the repository of the implementation of Deque if you want to learn more about the details. Your input is greatly appreciated. Feel free to submit a pull request or report an issue if you have any ideas for enhancements.

GitHub - tornikegomareli/Deque: 🦸‍♂️A Deque collection type implemented with Swift's protocols: Sequence, Collection, MutableCollection, and BidirectionalCollection with using of circular buffer to maximize memory usage

If you could take a moment to show your support by starring the repository on Github, following me on Medium, and applauding the article, I would truly appreciate it. Your involvement means a lot to me!

Thanks for reading, happy coding ⚡

]]>
<![CDATA[Introduction to Actors in Swift: Origins and Background]]> https://tgomareli.medium.com/introduction-to-actors-in-swift-origins-and-background-3e268f3d4948?source=rss-e061295e669a------2 https://medium.com/p/3e268f3d4948 Fri, 24 Mar 2023 15:57:56 GMT 2023-03-24T16:03:44.967Z

Intro 🔖

Sometimes audience thinks that Actors are a relatively new programming concept that has gained popularity in recent years due to their ability to simplify concurrent programming. But, Actors were first introduced by Carl Hewitt in the 1970s as a way to manage concurrency in distributed systems.

In traditional concurrency models, such as threads, it can be difficult to manage access to shared resources, leading to synchronization issues and race conditions. Actors offer a different approach to concurrency by isolating state and behavior within a single entity, which can only be accessed by passing messages.

The idea of message passing is not new in computer science and has similarities to other concepts, such as the Actor model and Communicating Sequential Processes (CSP). In the Actor model, which was introduced by Hewitt, there are independent entities, or actors, that communicate with each other through messages. In CSP, which was introduced by Tony Hoare, processes communicate with each other through channels.

The Actor model and CSP are similar in that they both offer a way to manage concurrency through message passing, and they both isolate state and behavior within individual entities. This isolation ensures that there are no synchronization issues or race conditions because access to shared resources is managed by passing messages, rather than through direct access.

In the context of Swift, actors provide a new way to manage concurrency that is more secure and easier to reason about than traditional concurrency models. By isolating state and behavior within a single entity, actors eliminate many of the synchronization issues and race conditions that can arise in traditional concurrency models.

actors have their roots in computer science and draw inspiration from concepts such as the Actor model and CSP. The use of message passing and state isolation makes actors a powerful tool for managing concurrency in modern programming languages such as Swift.

If you have never seen this video, you are lucky. Here is the three genius programmer talking about Actor models.

Actors in Swift 🕊️

Prior to the introduction of Actors, developers relied on traditional synchronization mechanisms like locks, semaphores, and dispatch queues to coordinate access to shared resources in concurrent programming. However, these mechanisms have several drawbacks that can make concurrent programming difficult and error-prone.

For example:

  1. Deadlocks: If locks are not acquired and released in the correct order, it can lead to a deadlock where two threads are waiting for each other to release their respective locks.
  2. Race conditions: When multiple threads access shared resources concurrently, it can lead to race conditions where the behavior of the program becomes unpredictable and non-deterministic.
  3. Scalability: Traditional synchronization mechanisms can be difficult to use when scaling up to large numbers of threads or when coordinating access to highly concurrent data structures.
  4. Debugging: Debugging concurrency issues can be challenging and time-consuming, as the bugs may only occur intermittently and be difficult to reproduce.

To address these issues, Swift introduced a new concurrency model based on Actors. Actors as we already mentioned provide a higher-level abstraction for concurrent programming, making it easier to reason about and write correct concurrent code.

Actors as essential objects can encapsulate state dan can only be accessed by sending them messages. When an actor receives a message, it executes the message handler on its own thread, ensuring that its state is accessed in a serialized, thread-safe manner. This eliminates the need for locks and semaphores and makes it much easier to reason about concurrency.

If you know me, you know I love learning how things work behind the scenes. Let’s dive in and see how this is done in Swift.

While the exact implementation details of actors in Swift are not publicly available, we can make some educated guesses, based on the behaviour of actors and the language features that are available in Swift.

Let’s consider we have some kind of actor in Swift

actor MyActor {
var count: Int = 0

func increment() {
count += 1
}

func getCount() -> Int {
return count
}
}

// Create an instance of the actor
let myActor = MyActor()
// Send messages to the actor
Task {
await myActor.increment()
}
Task {
let count = await myActor.getCount()
print("Count: \(count)")
}

Under the hood, the MyActor the class would be transformed into a special kind of object that is managed by the Swift runtime. This object would encapsulate the actor's state (in this case, the count variable), and provide methods for interacting with that state (in this case, the increment and getCount methods).

When a message is sent to the actor (in this case, using the await keyword), the Swift compiler would generate code that constructs a message object and places it in the actor's message queue. The message object would contain a reference to the method that should be executed on the actor (in this case, either increment or getCount), along with any parameters that were passed to the method.

The Swift runtime would then manage the processing of messages in the actor’s queue. When the actor is ready to process messages, the runtime would acquire a lock on the actor’s message queue and remove the next message from the queue. The runtime would then execute the method that is associated with the message, passing in any parameters that were included in the message object. The runtime would also ensure that access to the actor’s state is thread-safe, using locks and memory barriers to ensure that the state is accessed in a consistent and predictable manner.

Once the method has been executed, the runtime would release the lock on the message queue, allowing other messages to be processed. If the method returns a value (as in the case of the getCount method), the value would be returned to the sender of the message using a callback mechanism.

The implementation of actors in Swift would be quite complex in reality, because of involving a combination of low-level synchronization primitives and high-level abstractions. However, by providing a simple and intuitive programming model for concurrent programming, actors in Swift really make it easier to write correct and thread-safe code.

Now that we have delved into the historical background and examined the technical intricacies of how actors function under the hood, we are well-equipped to construct a realistic problem simulation. We will consider the challenges we face and explain how actors can help solve these problems. Next, we will implement the solution. Finally, we can draw some conclusions.

Let’s think about a realistic concurrency problem that can lead to a race condition. Imagine an application that tracks the number of views for different articles. When a user views an article, the application increments the corresponding view count. If multiple users view the same article simultaneously, a race condition may occur, leading to an incorrect view count. (Remember, the examples are not platform-specific. They are just generic programming examples in a programming environment)

Consider the following non-actor code:

class Article {
let id: Int
var viewCount: Int
init(id: Int) {
self.id = id
self.viewCount = 0
}
func incrementViewCount() {
viewCount += 1
}
}

let article = Article(id: 1)
DispatchQueue.concurrentPerform(iterations: 10) { _ in
article.incrementViewCount()
}
print("Total view count: \(article.viewCount)")

In this example, we use DispatchQueue.concurrentPerform to simulate 10 concurrent requests to increment the view count for the same article. Since the incrementViewCount method is not thread-safe, the final view count may be incorrect due to the race condition.

Now, let’s refactor the code using actors to resolve the race condition:

actor Article {
let id: Int
private(set) var viewCount: Int
init(id: Int) {
self.id = id
self.viewCount = 0
}
func incrementViewCount() {
viewCount += 1
}
}
let article = Article(id: 1)
DispatchQueue.concurrentPerform(iterations: 10) { _ in
Task {
await article.incrementViewCount()
}
}
Task {
let finalViewCount = await article.viewCount
print("Total view count: \(finalViewCount)")
}

So the original problem demonstrated a race condition in a multi-threaded environment when incrementing the view count of an article. This issue occurred because multiple threads accessed and modified the shared viewCount variable without proper synchronization, leading to unexpected results.

To solve this problem, we introduced the Article actor to manage and protect access to the mutable state. By converting the Article class to an actor, we ensured that only one task could access the viewCount property at a time, eliminating the race condition and guaranteeing the correct view count.

The revised implementation using the Article actor effectively handles concurrency and provides a safer, more reliable solution for managing shared states in a multi-threaded environment.

If you are still having difficulty grasping the concept of race conditions, it is a separate topic, and I highly recommend visiting this Stack Overflow thread for further information. Afterward, you can conduct additional research and read more about it. However, for now, let’s focus on creating a real-world example to illustrate race conditions more effectively.

Busy Cofee shop ☕️

Consider a busy coffee shop where multiple baristas are preparing drinks for customers.

In this scenario, the coffee shop represents the application, baristas represent threads or tasks, and customers represent data or resources that need to be managed concurrently. Just like in a multi-threaded environment, multiple baristas work in parallel to handle customer orders more efficiently.

However, imagine that there is only one coffee machine available to make espresso. If multiple baristas try to use the coffee machine at the same time without any coordination, this could lead to chaotic situations or even accidents (similar to race conditions). To prevent this, the coffee shop could introduce a system to manage access to the machine, ensuring that only one barista uses it at a time.

In this analogy, the coffee machine represents a shared resource in a multi-threaded application, and the system managing access to it is similar to an actor. By using actors in our application, we can serialize access to shared resources and prevent race conditions, much like the coffee shop ensuring that only one barista uses the coffee machine at a time.

By understanding this analogy, beginners can appreciate how actors help manage concurrency and protect shared resources in a more intuitive way.

While actors in Swift offer many advantages for concurrent programming, there are also some drawbacks to consider before using it.

  1. Limited interoperability: Actors may not be easily integrated with existing codebases or libraries that don’t support Swift’s concurrency features. You may need to create wrappers or use other techniques to bridge the gap between the old and new code.
  2. Restrictive access control: Actors enforce strict isolation, which can sometimes be too restrictive. You may need to refactor your code to work within the constraints of actor isolation, which could lead to less flexible designs.
  3. Compatibility: As actors were introduced in Swift 5.5, they are not available in earlier versions of the language. If you’re targeting platforms or environments that require older versions of Swift, you won’t be able to use actors.

🙌 Even with a few drawbacks, actors are a fantastic feature for Swift developers. They add a unique Swift-like quality, making the code feel more in line with the Swift spirit. If you share my enthusiasm for Swift, you’ll understand this feeling.

I hope this article has helped you gain some understanding of actor models in Swift. Enjoy coding! 😊

]]>
<![CDATA[Swift  —  Race condition ები, Lock ები და Thread safety]]> https://medium.com/ka-ge/swift-race-condition-%E1%83%94%E1%83%91%E1%83%98-lock-%E1%83%94%E1%83%91%E1%83%98-%E1%83%93%E1%83%90-thread-safety-c18e5dafca00?source=rss-e061295e669a------2 https://medium.com/p/c18e5dafca00 Tue, 09 Aug 2022 07:00:21 GMT 2022-08-09T07:00:21.307Z Swift — Race condition ები, Lock ები და Thread safety

პროგრამირების რაღაც ეტაპზე ყველანი ვეჯახებით Race condition ის პრობლემას და როცა ჯერ გამოცდილება არ გვაქვს მსგავსი პრობლემების მოგვარების, სრული სიგიჟე გვგონია არსებული სიტუაცია და ვერაფრით ვერ ვხვდებით თუ რატომ არ მუშაობს კონკრეტული გადაწყვეტილება. არადა თითქოს ყველაფერი სწორია, Debug ითაც კი კონკრეტულ value ებს ვამოწმებთ, სწორადაა მაგრამ უცბათ ყველაფერი ისევ თავდაყირა დგება.

მოდი წოტა ვისაუბროთ ფუნდამენტალურ პრობლემებზე პარალელიზმის და შემდეგ გადავიდეთ კონკრეტულ საკითხებზე.

რა არის Race condition ი ?

Race condition ი კონკურენტულ პროგრამირებაში არის სიტუაცია სადაც ორი კონკურენტული thread ი ან process ი მიწვდება ისეთ რესურსს, რომელიც ორივესთვის წვდომადია და ეცდება მათ მუტაციას (შეცვლას ან რაიმე ოპერაციას, რომელიც გამოიწვევს რეზულტატის შეცვლას), ასეთ შემთხვევაში რეზულტატის საბოლოო მნიშვნელობა დამოკიდებულია იმაზე თუ რომელი thread ი ან process ი მიწვდება პირველი მას. (შემდეგ მაგალითებში thread ებს მოვიხსენიებ, მხოლოდ თუმცა იცოდეთ რომ იგივე case ები, პროცესებზეც ვრცელდება)

როდესაც ორი ასინქრონული thread ი პარალელურად ეშვება, ჩვენ არასდროს ვიცით თუ რომელი მორჩება პირველი. გამოდის, რომ თუ ეს ორი thread ი ერთი და იგივე mutable state ს ცვლის ჩვენ აპრიორში არასდროს გვეცოდინება თუ რომელი thread ი მივა მასთან პირველი, გამოდის რომ რეზულტატი ყოველ გაშვებაზე სხვადასხვანაირი შეიძლება გვქონდეს, რაც ფუნდამენტალურად არასწორ computing ს ნიშნავს.

მოდი ვნახოთ მაგალითი და გამოვიწვიოთ Race condition ი Swift ში.

შევქმნათ ორი ბანკის ანგარიში, ორივეზე დავსვათ ერთი და იგივე თანხები, ამ შემთხვევაში 100 ლარი. შემდეგ შევქმნათ ბანკის ექაუნთი და აქვე მისი კლასი.

  1. გვაქვს final ტიპის Bank ის კლასი (რატომ final ? ჩემი წინა სტატია წაიკითხეთ)
  2. გვაქვს transfer ის ფუნქცია, რომელიც იღებს პარამეტრად გადასარიცხ თანხის ოდენობას, რომელი account იდან ხდება გადარიცხვა და რომელ account ზე.
  3. ვაბრუნებთ true ს წარმატების შემთხვევაში.

ჯერ ვნახოთ თუ როგორი შედეგი გვექნებოდა single-thread გარემოში, სადაც transfer ის ორჯერ შესრულება მოხდება სინქრონულად.

გამომდინარე, რომ ორივე ანგარიშზე მხოლოდ 100 ლარია, პირველი გადარიცხვის შემდეგ მეორე ანგარიშზე იქნება 150 ლარი, რადგან პირველიდან გადავრიცხეთ მეორეზე 50 ლარი. ხოლო მეორე გადარიცხვის დროს ვცადეთ 70 ლარის გადარიცხვა, თუმცა ანგარიშზე მხოლოდ 50 ლარი გვაქვს დარჩენილი და ოპერაცია არ შესრულდება, რადგან Bank ის კლასში ამის პრევენციისთვის ლამაზი guard ი გვიწერია.

გამოდის, რომ ყველანაირი სინქრონული ოპერაციის დასრულების შემდეგ ჩვენს ანგარიშებზე თანხები შემდეგნაირად გადანაწილდება

  • bankAccountOne / 50 ლარი
  • bankAccountTwo / 150 ლარი

ყველაფერი რიგზეა და ყველაფერი ისე მუშაობს, როგორც უნდა მუშაობდეს. მოდით ეხლა ეს ორი transfer ის ფუნქცია დავაპარალელოთ და ვნახოთ როგორ იმუშავებს ასინქრონულ/multi-thread გარემოში ეს ყველაფერი.

წარმოვიდგინოთ რომ სხვადასხვა thread ი აკეთებს ამ ფუნქციების გამოძახებას პარალელურად

ასეთ შემთხვევაში ორი სახის პრობლემა გვაქვს.

  1. არ ვიცით პირველი რომელი ასინქრონული ფუნქცია მიწვდება state ს
  2. დამოკიდებულების შემთხვევაში, ყოველ გაშვებაზე შესაძლოა სხვადასხვა შედეგი მივიღოთ.
  3. Data race მოხდეს და ორი THREAD ის წაკითხვა, ჩაწერის პროცესი ერთმანეთს დაემთხვეს.

შეიძლება პირველი thread ის ჩაწერამდე, მოხდეს მეორე thread ის თანხის წაკითხვა ამ დროს თანხა amount ზე მეტი იყოს ანგარიშზე, მაგრამ შემდეგ thread 1 მა განახორციელოს გადარიცხვა, და თანხა დარჩეს 50 ლარი, ამ დროს thread 2 ს შემოწმების და წაკითხვის ეტაპი უკვე გავლილი აქვს და როდესაც გადარიცხვას თვითონაც გააკეთებს, ამ დროს რეალურად ანგარიშზე მხოლოდ 50 ლარი იქნება დარჩენილი, მაგრამ 70 ლარს გადარიცხავს. შედეგი შემდეგნაირი გვექნება

  • bankAccountOne / -30
  • bankAccountTwo / 220

ამ პრობლემას დებაგით ვერ მოვაგვარებთ, რადგან ლოგიკურად ყოველ გაშვებაზე შესაძლოა სხვადასხვა სახის პრობლემას დავეჯახოთ.

მსგავსი პრობლემების მოსაგვარებლად პროგრამირების ენებში გვაქვს lock ები და mutex ები, მათ synchronization context ებსაც ეძახიან.

ამ სტატიაში ზუსტად სხვადასხვა lock ების პრინციპებზე ვისაუბრებთ Apple ის პლატფორმებზე და გავარჩევთ სხვადასხვა synchronization context ის მუშაობის პრინციპს და გამოყენების არეალს.

lock/mutex ი გვეხმარება რომ კონკრეტულ რეგიონში მხოლოდ ერთი thread ი იყოს აქტიური რაც გვაძლევს საშვალებას ავირიდოთ თავიდან Data race ი და Race condition ი.

არსებობს სხვადასხვანაირი ტიპის ლოქები

  • Blocking lock ები აძინებენ thread ს სანამ ელოდებიან მეორე thread ს რომ გაანთავისუფლონ ლოქი. ესეთი ლოქის გამოყენება ყველაზე ხშირია პრაქტიკაში.
  • Spinlock ები იყენებენ loop ს რომ მუდმივად შეამოწმონ განთავისუფლდა თუ არა lock ი. ესეთი მიდგომა ბევრად უფრო ეფექტურია თუ ლოდინის დრო მცირეა thread ისთვის.
  • Reader/writer lock ი საშუალებას აძლევს რამდენიმე reader thread ს რომ ერთროულად შევიდნენ რეგიონში, მაგრამ რეგიონს ბლოკავენ ყველა სხვა thread ისთვის მათშორის reader ისთვისაც თუ writer thread ს უჭირავს კონკრეტული lock ი. ეს შეიძლება იყოს სასარგებლო როდესაც ხდება მხოლოდ წაკითხვა პარალელურად სხვადასხვა thread იდან, მაგრამ საშიში როდესაც გვჭირდება ჩაწერა რადგან შეიძლება ამ დროს სხვა thread ები განახორციელებდნენ ჩაწერას ან წაკითხვას.
  • Recursive lock ები უფლებას აძლევენ ერთ thread ს რომ რამოდენიმეჯერ დაიკავონ რეგიონი. არა-რეკურსიული lock ებმა შეიძლება გამოიწვიონ deadlock ი ან crash ი თუ რამოდენიმეჯერ დაიკავებენ ერთი და იგივე რეგიონს.

იმისთივს რომ ეს ყველაფერი ტექნიკურად საკუთარ კოდში განვახორციელოთ, Apple ი ამ ყველაფერს სხვადასხვა programming interface ის სახით გვაძლევს.

  • pthread_mutex_t
  • pthread_rwlock_t
  • DispatchQueue
  • OperationQueue როდესაც სერიალად არის კონფიგირებული
  • NSLock
  • os_unfair_lock

pthread_mutex_t არის blocking lock ი, რომელიც რეალურად შეიძლება დაკონფიგურირდეს ისე, რომ გარდაიქმნას recursive lock ად.

pthread_rwlock_t არის blocking reader/writer lock ი.

DispatchQueue არის blocking lock ი. ის შეიძლება დაკონფიგურირდეს reader/writer lock ად თუ გამოვიყენებთ კონკურენტურლ queue ს და ბარიერ ბლოკებს. ასევე DispatchQueue ასაპორტებს lock ის რეგიონის ასინქრონულ execution ს.

OperationQueue შეიძლება გამოვიყენოთ როგორც blocking lock ი. როგორც DispatchQueue ისიც ასაპორტებს lock ის რეგიონის ასინქრონულ execution ს.

NSLock არის არის ჩვეულებრივი blocking lock ი და რეალურად არის objetive-c ის კლასი.

os_unfair_lock ი არის low-level spinlock ი.

თითქოს ყველა lock ი ასე თუ ისე ერთი პრინციპით მუშაობს და საკმაოდ გასაგებია ეს ყ ველაფერი. მაგრამ სხვა lock ებთან შედარებით spinlock ების ახსნა წოტა არა ინტუიტიურია. მოდი დეტალურად გავარჩიოთ რა არის Spinlock ი და რითი განსხვავდება სხვებისგან.

რა არის Spinlock ი ?

როგორც ზევით ვახსენე Spinlock ები ცალკეული ტიპის lock ად გამოვყავი. Spinlock ები ძალიან მარტივი მექანიზმით მუშაობენ და ძალიან ეფექტურები არიან თუ სწორ დროს გამოვიყენებთ მათ.

თუ ოდნავ მაინც გვესმის low-level ში როგორ მუშაობენ thread ები და process ები, მივხვდებით თუ რაოდენ მაგარი გადაწყვეტილებაა spinlock ები ისეთი thread ებისთვის, რომლებსაც ლოდინის დრო მცირე აქვთ.

Spinlock ებს ძირითადად kernel thread ები იყენებენ და user-mode ში მათი გამოყენება წოტა არა-ეფექტურია. როდესაც რეგულალურ lock ებს ვიყენებთ, ოპერაციული სისტემა thread ს wait state ში ამყოფებს და აფერხებს მას იმავე ბირთვზე სხვა thread ების scheduling ით. ამას დიდი performance penalty აქვს თუ ლოდინის დრო ძალიან მცირეა, რადგან thread ი ახლა ასევე უნდა დაელოდოს საკუთარ Preemption ს რომ მიიღოს CPU time ი ანუ quanta თავიდან და შემდეგ განაგრძოს მუშაობა, ჩვენ კი როგორც ვიცით quanta ს გამოთვლა საკმაოდ რთული და ფრთხილი პროცესია. (quanta ს შესახებ მეტი ინფორმაციისთვის ჩემს სტატიას ეწვიეთ ამ ბმულზე)

Spinlock ებს არ ჭირდებათ preemption ი, რადგან არ გადადიან wait state ში რადგან ისინი იყენებენ loop ს და ტრიალებენ იქამდე სანამ რეგიონი არ განთავისუფლდება. ეს პროცესი quanta ს დაკარგვის პრევენციას ახდენს და მაშინვე აძლევს საშვალებას გააგრძელოს thread მა მუშაობა, როგორც კი lock ი განთავისუფლდება, რადგან ამ დროს thread ს state ი არ ეცვლება, რის გამოც აღარაა საჭირო quanta ს გამოთვლა და preemption ი. ის უბრალოდ loop ში ტრიალებს იქამდე სანამ რაიმე condition ი არ მოხდება.

Value type lock ები

  • pthread_mutex_t
  • pthread_rwlock_t
  • os_unfair_lock

ზემოთ ჩამოთვლილი lock ები value ტიპებია და არა reference ტიპები. ეს ნიშნავს, რომ თუ მათთან გამოვიყენებთ მინიჭების ოპერატორს, მოხდება კოპირება. ეს ძალიან მნიშვნელოვანია რადგან ამ ობიექტების კოპირება არავითარ შემთხვევაში არ შეიძლება. თუ ზემოთ ჩამოთვლილთაგან რომელიმე pthread ს დააკოპირებ, დაკოპირებული ობიექტი გამოყენებადი ვერ იქნება და შეიძლება crash იც კი გამოიწვიოს. pthread ის ფუნქციები რომლებიც მუშაობენ კონკრეტულ ტიპებზე ყოველთვის ელოდებიან რომ მნიშვნელობები ზუსტად იმ memory address ებზე იქნებიან სადაც მათი ობიექტები გამოიყვნენ, ამიტომ მათი დაკოპირება და სხვა ადგილას ჩასმა არც თუ ისე კარგი იდეაა.

როდესაც ამ ტიპებს გამოიყენებთ ფრთხილად უნდა იყოთ, რომ მათი Struct ში მოთავსებით ან closure ში capturing ით არ გამოიწვიოთ მათი შემთხვევითი კოპირება. (capturing ის შესახებ, ეწვიეთ ჩემს სტატიას closure ებზე)

ასევე pthread lock ებთან დასამახსოვრებელია ის ფაქტი, რომ მათი უბრალოდ ობიექტის შექმნა არ ნიშნავს მათ ინიციალიზაციას. lock ის ობიექტები სათითაოდ უნდა დაინიციალდნენ pthread_mutex_init ით ან pthread_rwlock_init ით.

ასევე არ დაგავიწყდეთ ამ ობიექტების წაშლა, რადგან მათზე ARC ი არ მუშაობს.

როგორ ვიყენებთ ზემოთ ჩამოთვლილ lock ებს Swift ში ?

DispatchQueue ს აქვს callback-based API რაც მას ხდის უსაფრთხოს გამოყენებისთვის. იმის მიხედვით თუ როგორ გინდათ გაეშვას თქვენი დალოქილი კრიტიკული სექცია შეგიძლიათ sync ან async ფუნქციები გამოიძახოთ.

სინქრონულ შემთხვევაში, API იმდენად კარგია რომ მას შეუძლია გაიგოს closure ში დაბრუნებული ტიპი და იგივე ტიპი დააბრუნოს sync ფუნქციიდანაც. ასევე შეგიძლიათ გაისროლოთ exception ები და API თვითონ დაჰენდლავს ასეთ შემთხვევებს.

იგივენაირად მუშაობს OperationQueue, მაგრამ არ შეუძლია დაჰენდლოს throw error ები და დამაბრუნებელი ტიპები როგორც DispatchQueue ს.

სხვა დანარჩენ lock ებს სჭირდებათ ცალ-ცალკე დაძახება locking ისთვის და unlocking ისთვის, და ეს საკმაოდ ართულებს სიტუაციას თუ რომელიმე მათგანი გამოგრჩებათ. ასეთ შემთხვევაში არ გექნებათ compile-time შეცდომები, მაგრამ გექნებათ სხვადასხვა შეცდომები runtime ში.

მათი გამოყენება შემდეგნაირად გამოიყურება Swift ში

კომპლექსური კრიტიკული რეგიონები

მარტივ კრიტიკულ რეგიონებში lock ების გამოყენება საკმაოდ მარტივია, მითუმეტეს თანამედროვე ენებში. მაგრამ რა უნდა ვქნათ თუ კრიტიკული სექციები ოდნავ უფრო კომპლექსურია და ესე გამოიყურება.

წოტახანი სტატიის კითხვა შეწყვიტეთ და ამ კოდს დააკვირდით, ხვდებით რა პრობლემა გვაქვს აქ?

ისეთ შემთხვევაში როდესაც earlyExitCondition ი true იქნება, ფუნქციიდან ტიპის დაბრუნება მოხდება რაც იმას ნიშნავს რომ return ის ქვევით არსებული კოდი ფუნქციაში აღარ შესრულდება. ეს ყველაფერი კი გამოიწვევს კონკრეტული სექციის სამუდამოდ ჩაკეტვას სხვა thread ებისთვის, რადგან unlock ი აღარ მოხდება. ესეთი პრობლემა დიდ პროექტში აქილევსის ქუსლია და მათი პოვნა და მიგნება ძალიან რთული.

იგივე პრობლემის წინაშე დავდგებით თუ exception ს გავისვრით.

ასეთ შემთხვევაში აუცილებელია lock ების გამოყენების დროს საჭიროზე მეტად დისციპლინირებულები ვიყოთ.

ასეთი პრობლემების გადასაჭრელად შესანიშნავი გამოსავალი არის defer ბლოკი. რა არის defer ბლოკი ? defer ი ფუნქციაა, რომელიც პარამეტრად closure ს იღებს. defer ფუნქციას ვაწვდით closure ს რომელიც გამოიძახება მაშინ როდესაც არსებული ფუნქციის scope ი მორჩება და დასრულდება.

ზემოთ მოყვანილ მაგალითში, როგორც არ უნდა დასრულდეს ფუნქცია scope ის დასრულების შემდეგ ბოლოს მაინც defer ბლოკი შესრულდება, რაც 100% ით გვარწმუნებს რომ unlock ი ყოველთვის მოხდება.

თუმცა ჩემი აზრით ძალიან ამახინჯებს კოდს ყოველი lock ის დროს defer ბლოკის გაკეთება, ამიტომ კარგი იქნება თუ callback-based wrapper ს გავაკეთებთ როგორიც DispatchQueue ს აქვს.

ერთხელ შევქმნით withLocked ტიპის generic ფუნქციას და ყოველი lock ის შესრულების დროს closure ად ჩავაწოდებთ იმ რეგიონს, რომელიც გვინდა რომ დალოქილი იყოს სხვა thread ებისთვის.

იგივე wrapper ის შექმნა value type lock ებისთვის წოტა განსხვავებული იქნება. NSLock ი reference ტიპია, მაგრამ როგორ მოვიქცეთ თუ მსგავსი აბსტრაქციის შექმნა გვინდა pthread lock ებისთვის ?

ასეთ შემთხვევაში ყოველთვის lock ის მიმთითებელი ანუ პოინტერი უნდა მივიღოთ პარამეტრად და არა პირდაპირ lock ის ობიექტი, რადგან შევძლოთ მის რეალურ მისამართთან წვდომა და არა დაკოპირებულ ობიექტთან, თუ პოინტერს არ გამოვიყენებთ Swift ი ამ ობიექტის ავტომატურ კოპირებას მოახდენს ფუნქციის გამოძახებისას რადგან ეს ობიექტი value ტიპია.

pthread lock ისთვის აბსტრაქციის ვერსია შემდეგნაირად გამოიყურება

როგორ ავირჩიოთ თუ რომელი Locking API გამოვიყენოთ ?

DispatchQueue ერთ-ერთი საუკეთესო არჩევანია. მას აქვს ლამაზი Swift ის API. მარტივი callback-based გამოყენება, Apple ისგან დიდი ყურადღება და ბევრი სასარგებლო feature ი. DispatchQueue ს ბევრი advanced გამოყენება აქვს. შეგვიძლია გავაკეთოთ timer scheduling ი ან event source ები. ავაწყოთ ლოქების კომპლექსური იერარქია. შევქმნათ custom კონკურენტული queue ები, რომლებიც შეგვიძლია გამოვიყენოთ reader/writer lock ებად. ერთი მარტივი ფუნქციის შეცვლით შეგვიძლია შევცვალოთ სინქრონულობა / ასინქრონულობით. API არის მარტივი და ძნელია შეცდომების დაშვება, რაც არ უნდა კომპლექსური კრიტიკული სექცია გვქონდეს. ზუსტად ამის გამოა GCD ბევრი ინჟინრის საყვარელი ხელსაწყოა.

os_unfair_lock ი ყველაზე სწრაფი lock ია ჩვენს გარემოში, მიზეზებზე ზევით უკვე ვისაუბრეთ. თუ უბრალოდ გვინდა რომ კრიტიკული სექცია გამოვაცხადოთ, მასზე სწრაფი performance ი გვქონდეს და განსაკუთრებული ფუნქციონალი არ გვჭირდება მაშინ ეს ლოქი ზუსტად ისაა რაც გვჭირდება. Swift ის memory model ის გამო მისი პირდაპირ გამოყენება არ შეიძლება და მას ყოველთვის რაიმე აბსტრაქცია უნდა შევუქმნათ.

pthread_mutex ი low-level lock ის მექანიზმია რომელიც შეგიძლიათ მაშინ გამოიყენოთ როდესაც თქვენს Swift ის კოდს ხშირი შეხება აქვს C ან C++ ის API სთან. os_unfair_lock თან შედარებით წოტა უფრო დიდი ობიექტია და მისი გამოყენების დროს გიწევთ მექანიკურად აკონტროლოთ გამოყოფილი მეხსიერება.

pthread_rwlock ი reader/writer lock ია , და ბევრ კარგ თვისებას არ იძლევა რომელიც შეიძლება ღირდეს მის მექანიკურ კონტროლად. ამას ჯობია DispatchQueue გამოიყენოთ და დააკონფიგუროთ სხვადასხვა queue ებით reader/writer lock ად.

NSLock ი Objective-c ის აბსტრაქციაა, რომელიც wrapper ია pthread_mutex ის და მისი გამოყენებას არ ჭირდება მექანიკური მეხსიერების კონტროლი, ასევე აქვს ისეთი ფუნქციონალი როგორიცაა timeout ების დაყენება. თუმცა os_unfair_lock თან შედარებით ძალიან ნელია Objective-c messaging სისტემის გამო.

Async/Await Actor ები Swift 5.5 ში დაემატა. კონკრეტული მიდგომა კონტექსტუალურად ცვლის ჩვენს მუშაობას ასინქრონულობასთან და thread ებთან და ასევე Actor ები ფუნქციონალურად ცვლის ჩვენს მიდგომას სინქრონიზაციის კონტექსტებთან. Swift ის async/await ს და Actor ებს შეგვიძლია ცალკე სტატია მივუძღვნათ.

მოკლედ რომ ვთქვათ ყოველდღიური lock ისთვის საუკეთესო ვარიანტი DispatchQueue ია და თუ პერფორმანსი გვჭირდება მაშინ os_unfair_lock ი. დანარჩენების გამოყენებას სპეციფიკური მიზეზები სჭირდება და მათი გამოყენება დამატებით overhead ს ითხოვს.

Conclusion

Swift ს Actor ებამდე ენის დონეზე thread ის სინქრონიზაცია არ გააჩნდა, მაგრამ Apple ის API ების დახმარებით აკეთბდა ყველაფერს. GCD Apple ის ერთ-ერთი საუკეთესო ხელსაწყოა, რომელმაც დროსაც გაუძლო და დეველოპერებსაც საკმაო ცოდნა აქვთ მასზე დაგროვებული. სპეციფიური შემთხვევებისთვის სადაც GCD ს ვერ გამოვიყენებთ, სხვა მრავალი ხელსაწყო გვაქვს რომლითაც შეგვიძლია ჩავანაცვლოთ gcd.

ყველაზე საინტერესო თანამედროვე Swift ში Actor ებია, ყველა API სგან განსხვავებით Actor ები compile-level აბსტრაქციებია. რაც იმას ნიშნავს, რომ მათ race-condition ების აღმოჩენა შეუძლიათ compile-time ში. სავარაუდოდ ნელ-ნელა thread ების სინქრონიზაციისთვის Actor ები ყველაზე პოპულალური და ძლიერი ხელსაწყოები გახდება, ამიტომ ვფიქრობ იმსახურებს ცალკე სტატიას, სადაც დეტალურად გავარჩევთ როგორაა Actor ები იმპლემენტირებული Swift ის კომპილატორში და როგორ მუშაობს ის ფარდის უკან.

მადლობა


Swift  —  Race condition ები, Lock ები და Thread safety was originally published in ka_GE on Medium, where people are continuing the conversation by highlighting and responding to this story.

]]>
<![CDATA[Swift - რა არის Method dispatch ი? რა ტიპის dispatch ები გვაქვს და როგორ მუშაობენ ისინი.]]> https://medium.com/ka-ge/swift-%E1%83%A0%E1%83%90-%E1%83%90%E1%83%A0%E1%83%98%E1%83%A1-method-dispatch-%E1%83%98-%E1%83%A0%E1%83%90-%E1%83%A2%E1%83%98%E1%83%9E%E1%83%98%E1%83%A1-dispatch-%E1%83%94%E1%83%91%E1%83%98-%E1%83%92%E1%83%95%E1%83%90%E1%83%A5%E1%83%95%E1%83%A1-%E1%83%93%E1%83%90-%E1%83%A0%E1%83%9D%E1%83%92%E1%83%9D%E1%83%A0-%E1%83%9B%E1%83%A3%E1%83%A8%E1%83%90%E1%83%9D%E1%83%91%E1%83%94%E1%83%9C-%E1%83%98%E1%83%A1%E1%83%98%E1%83%9C%E1%83%98-f7d06e190e25?source=rss-e061295e669a------2 https://medium.com/p/f7d06e190e25 Mon, 01 Aug 2022 05:13:30 GMT 2022-08-01T05:13:30.106Z

ოდესმე გიფიქრია თუ რა ხდება, როდესაც ფუნქციას ვეძახით?

არადა როგორი მარტივი ჩანს ხო? ვქმნით ფუნქციას, შემდეგ ვეძახით და ყველაფერი ჯადოსნური თავისით ხდება runtime ში. არადა რომ დავფიქრდეთ რამდენი კომპლექსურობაა თითოეული მეთოდის გამოძახების უკან.

გადატვირთული ფუნქციები, მემკვიდრეობითობა, მეხსიერების გამოყოფა, ოპტიმიზაცია, პარამეტრების დაკოპირება და ა.შ

დღეს სტატიაში ვისაუბრებთ თუ რა ტიპის method dispatch ები გვაქვს Swift ში, ვიმსჯელებთ მათი მუშაობის პრინციპის შესახებ. კომპილატორის ოპტიმიზაციებზე და იმაზე თუ რეალურად როგორ აპროცესებს method ის გამოძახებას თვითონ runtime ი, როგორ შეგვიძლია შევზღუდოთ compiler overhead ი და გავზარდოთ performance ი ჩვენს ყოველდღიურ საქმიანობაში.

რა არის Method dispatch ი ?

Method dispatch ს ვეძახით პროცესს, რომელიც სისტემას ჭირდება იმის დადგენისთვის, რომ მიხვდეს კონკრეტული ფუნქციის რომელი იმპლემენტაცია გამოიძახოს.

წარმოვიდგინოთ რომ გვაქვს მარტივი ფუნქცია, რომელსაც ვეძახით.

რა ხდება ამ დროს მის უკან ? როგორ ხვდება კომპილატორი თუ სად არის ეს მეთოდი ? როგორ ხვდება კომპილატორი თუ რომელ მეთოდს უნდა დაუძახოს თუ ეს ფუნქცია გადატვირთულია, ხოლო კლასს რამოდენიმე შვილობილი კლასი ყავს. ყველაფერ ამაში Method dispatch ები გვეხმარება, როგორც compile-time ში ასევე runtime ში.

Swift ში 3 სახის ფუნქციის dispatch ი გვაქვს.

  • Static dispatch (direct dispatch)
  • Table dispatch (virtual dispatch)
  • Message dispatch

დავიწყოთ თითოეული dispatch ის ჩაშლით და სიღრმისეულად გარჩევით.

Static Dispatch

ფუნქცია, რომელსაც არ შეიძლება ყავდეს გადატვირთული ანუ overriden ვარიანტი, მასზე კომპილატორი სტატიკური dispatch ით ხელმძღვანელობს.

სტატიკურ dispatch ს ვეძახით, ფუნქციის ისეთ გამოძახებას სადაც runtime მა ზუსტად იცის ამ ფუნქციის მისამართი და დარწმუნებულია, რომ ამ ფუნქციას მხოლოდ ერთი იმპლემენტაცია აქვს, ასეთ დროს გაშვებულ აპლიკაციაში მარტივია რომ runtime ი გადახტეს პირდაპირ კონკრეტულ მეხსიერების ზონაზე და დაიწყოს ფუნქციის execution ი, განსხვავებით სხვა dispatch ებისგან.

მსგავსი dispatch ის მისაღწევად swift ში method ები შეგვიძლია გამოვაცხადოთ შემდეგი keyword ებით.

  • static
  • final

ასევე ყველა ის ფუნქცია, რომელიც value type ებშია გამოცხადებული default ად final მეთოდებია, რომლებიც როგორც ზემოთ ავღნიშნე სტატიკური dispatch ით მუშაობენ.

value typ ებს არ შეუძლიათ იყვნენ გადატვირთული, რადგან Swift ში აკრძალულია inheritance ბმა მათ შორის.

ამიტომ, როდესაც მაგალითად ვქმნით სტრუქტურას, სადაც გვაქვს ფუნქციები ამ დროს კომპილატორმა ზუსტად იცის, რომ ამ ფუნქციებს ვერასდროს ეყოლებათ გადატვირთული ფუნქციები, ამიტომ თამამად შეუძლია პირდაპირ მისამართით მიაკითხოს მათ.

Table Dispatch

ესეთი მეთოდის dispatch ი გამოიყენება default ად ყველა reference type ში გამოცხადებული ფუნქციებისთვის. (შეგახსენებთ Swift ში reference type ები არიან ყველა class და closure ი, ხოლო value type ები ყველა სტრუქტურა და enum ი.)

ესეთი dispatch ების დროს, compile time ში იქმნება virtual table ი რომელშიც ინახება კონკრეტული იმპლემენტაციები, კონკრეტული მეთოდების რომლებიც შემდეგ runtime ში გამოიძახება. Runtime ის დროს, virtual table ი უბრალოდ ფუნქციის პოინტერების მასივად გარდაიქმნება სადაც runtime ს შეუძლია ჩაიხედოს და კონკრეტული ლოკაცია ამოიღოს შესაბამისი ფუნქციის, შესაბამისი იმპლემენტაციისთვის.

მაგალითად, რომ წარმოვიდგინოთ ესეთი 2 პოლიმორფული ტიპის კლასი (პოლიმორფულია კლასი, თუ მას შეუძლია პოლიმორფიზმი სხვა კლასთან)

Parent ი და Child კლასი, რომელიც შვილია Parent ის სადაც 1 გადატვირთული ფუნქციაა იმპლემენტირებული.
პოლიმორფიზმის მაგალითი

მაგალითში კარგად ჩანს, რომ გვყავს მშობელი კლასი რომელსაც აქვს ორი member ფუნქცია. შემდეგ გვყავს შვილობილი კლასი, რომელსაც აქვს მხოლოდ ერთი ფუნქცია გადატვირთული, მემკვიდრეობით მიღებული ერთი ფუნქცია, ერთიც საკუთარი ახალი დამატებული. ანუ მემორის დონეზე runtime ში შვილობილ კლას ჯამში აქვს 3 ფუნქცია.

ზემოთ მოყვანილი მაგალითების დროს, როგორ უნდა მიხვდეს runtime ი თუ რომელი ფუნქციის იმპლემენტაცია გამოიძახოს ? Runtime ი ზუსტად compile-time ში დაგენერირებული virtual table ის მიხედვით მიხვდება, თუ რომელი იმპლემენტაცია უნდა გამოიძახოს.

მოდი ვნახოთ სტრუქტურულად როგორ შეიძლება გამოიყურებოდეს Virtual table ი

Virtual Table

Compile-time ში ჩვენი კოდის კომპილაციის დროს, ზუსტად ესეთი Virtual-table ი შეიქმნება მეხსიერებაში. სადაც method1 ს და method2 ს child ი მემკვიდრეობით იღებს, ხოლო თვითონ method3 ი შეძენილი ფუნქციაა, რომელსაც არაფერი კავშირი არ აქვს Parent თან.

ჩვენს შემთხვევაში Child კლასს მხოლოდ method2 ფუნქცია აქვს გადატვირთული რაც იმას ნიშნავს, რომ child ობიექტიდან method2 ის გამოძახება 0x227 მისამართზე წავა, ოღონდ 0xB000 მისამართიდან და შემოწმდება თუ ამ მისამართზე არის Child ის მიერ იმპლემენტირებული ეს ფუნქცია, თუ არის მოხდება პირდაპირ გამოძახება. თუ არა მაშინ runtime ი Child ის სუპერ კლასთან ანუ Parent თან წავა და იქ შეამოწმებს 0x227 მისამართზე თუ არის იმპლემენტაცია და თუ დახვდა მაშინ გამოიძახებს. ჩვენს შემთხვევაში method2 ის გამოძახება Child ში მოხდება, მაგრამ method1 ის გამოძახება child ობიექტიდან გამოიწვევს ზევით აღწერილ flow ს სადაც runtime ი იერარქიულად ეძებს იმპლემენტაციას და არ გამოიძახებს ფუნქციას იქამდე სანამ virtual table ში არ იპოვის შესაბამის მისამართზე რომელიმე იერარქიაში მის იმპლემენტაციას.

V-table ი compile-time ის დროს იქმნება, როდესაც SIL ი გენერირდება. (Swift intermediate language), ხოლო ფუნქციის იმპლემენტაციების ამორჩევის პროცესი რა თქმა უნდა runtime ში ხდება.

აქვე ვნახოთ დაგენერირებული SIL — ი ჩვენი Vtable ის მიხედვით

იმის მიუხედავად, რომ SIL ის წაკითხვა საკმაოდ ძნელია, მაინც შეგვიძლია დაკვირვების შემდეგ ვნახოთ, რომ Child კლასსაც და Parent კლასსაც საკუთარი Virtual table ები აქვთ, სადაც არის გადანაწილებული მათი ფუნქციები, მეხსიერების ალოკაცია და მეხსიერების დეალოკაცია.

თუ დააკვირდებით მემკვიდრეობით მიღებულ ყველა ფუნქციას აქვს @$SilGen10Parent ატრიბუტი, გარდა method2 ისა, რადგან შვილობილი კლასი ამ ფუნქციის გადატვირთვას ანუ override ს აკეთებს. ზუსტად ესე ხვდება runtime ი კონკრეტულ იერარქიაში აქვს თუ არა მეთოდს იმპლემენტაცია.

Swift ის ფაილის SIL ში გადასაყვანად, ეს ბრძანება უნდა გაუშვათ ტერმინალში. დამიჯერეთ, ქვევით ბევრი საინტერესო დეტალი დაგხვდებათ.

swiftc -emit-silgen -O <swift-file-name>.swift

ზემოთ აღწერილი Dispatch მიდგომა მუშაობს default ად ყველა reference type ისთვის, ამიტომ ამ dispatch ის მისაღწევად Swift ში არაფრის დაწერა არ გვიწევს, რადგან ყველა კლასის ფუნქცია default ად ამ მექანიზმით მუშაობს.

Message Dispatch

ჩვენ უკვე გავარჩიეთ Table dispatch ის შემთხვევაში როგორ წყვიტავდა runtime ი თუ რომელი ფუნქცია გამოეძახა მემორიდან, მაგრამ ამ შემთხვევაში წოტა უფრო დიდი პრობლემა გვაქვს.

თუმცა ეს პრობლემა რომ გავიგოთ, წოტა ისტორია და კონტექსტი უნდა ვიცოდეთ. ამიტომ წოტა დროში მოგზაურობა მოგვიწევს.

ძალიან დიდი ხნის წინ.

Objective-c ძალიან runtime ზე დამოკიდებული ენაა, runtime ის დროს უამრავი კოდის შემოწმება ხდება და ამას პლიუს ისიც კი შეგიძლია რომ ფუნქციის იმპლემენტაციაც კი შეცვალო runtime ში. ამას Method swizzling ს ვეძახით.

ენის ერთ-ერთი პლიუსია სწრაფი compile-time ი და ეს ზუსტად იმისგან მიიღეს, რომ ბევრი შემოწმება და რუტინული საქმე runtime ის პასუხისმგებლობა გახადეს. მაგალითად runtime ი აკეთებს ისეთ რაღაცეებს objective-c ში როგორიცაა:

  • კლასის შემოწმება თუ ის რაღაც X კლასის ნაწილია, ფუნქცია isMemberOfClass ით. ასევე runtime ი ამოწმებდა თუ რომელიმე X კლასისგან იყო წარმოქმნილი კონკრეტული კლასი isKindOfClass ფუნქციით.
  • შემოწმება თუ კლასს შეეძლო კონკრეტული message ის მიღება და შემდეგ დაპროცესება. respondToSelector ფუნქციით
  • დინამიურად ცვლიდა runtime ი method ის იმპლემენტაციას (method swizzling)
  • ასევე შეეძლო დაემატება ფუნქციის იმპლემენტაცია class_addMethod ფუნქციით.

ეხლა უკვე ვიცით, რომ objective-c ძალიან runtime ზე დამოკიდებული ენაა და compile time ში მყოფი პროგრამის სტეიტი საერთოდ არაა სანდო, რადგან runtime ს შეუძლია თითქმის ყველაფრის შეცვლა. ამან შეიძლება ძალიან დიდი პრობლემა შეგვიქმნას თუ Table dispatch ტექნიკას გამოვიყენებთ იმისთვის, რომ გავიგოთ თუ რომელ ფუნქციის იმპლემენტაციას დავუძახოთ. რატომ ?

V-table ის შექმნა Table dispatch ის დროს ხდება compile-time ში. Compile-time ში შექმნილ V-Table ი შეიძლება სწორად არ ასახავდეს რომელიმე მეთოდის იმპლემენტაციას, რადგან შეიძლება სამომავლოდ მოხდეს მათზე swizzling ი, ან შეიძლებ ახალი ფუნქციები დაემატონ runtime ში. გამომდინარე აქედან compile-time ში მიღებული გადაწვეტილება ვერ იქნება საყრდენი წერტილი ისეთი ენისთვის როგორიცაა Objective-c.

ამიტომ Message dispatch ი მთლიანად runtime ზეა დამოკიდებული, და კონკრეტულად რაიმე v-table ი აქ არ გვაქვს.

ეხლა როგორ ეძებს ფუნქციას Message dispatch ი ?

Objective-C Alan Key ს იდეებს მიყვებოდა და ბევრჯერ ინსპირაცია Smalltalk იდან იყო აღებული, ამიტომ ობიექტები ერთმანეთს message ების მიმოცვლით ესაუბრებოდნენ. იმისთივს, რომ A ობიექტს B ობიექტის ფუნქციისთვის დაეძახა, B ობიექტისთვის მესიჯი უნდა გაეგზავნა.

ამ ყველაფერს objc_msgSend() ფუნქცია აკეთებს.

კონკრეტული ფუნქცია 3 პარამეტრს იღებს

  1. მიმღები ობიექტი
  2. Message ის selector ი (ფუნქციის სახელი, რომელიც target object ში უნდა გამოიძახოს)
  3. არგუმენტები

მიმღების ობიექტს isa პოინტერი აქვს. Selector ები კი v-table ში ინახება. objc_msgSend() ფუნქცია მიყვება isa პოინტერს, რომ იპოვოს შესაბამისი სადაც არის გამოყოფილი მეხსიერება ამ მისამართით. თუ ვერ იპოვის მსგავს განყოფილებას მაშინ კლასის სუპერ კლასს ის პოინტერს იღებს და მასში იწყებს მეთოდის ძებნას. ბოლოს NSObject ამდე ადის და თუ მაინც ვერ მოხდა იმპლემენტაციის პოვნა, exception ი მოხდება. რა თქმა უნდა ეს მიდგომა ყველა dispatch თან შედარებით ყველაზე ნელია.

iOS დეველოპერები დღეს იძულებულები ვართ რომ გამოვიყენოთ objective-c ის runtime ი რამოდენიმე ადგილას. ყველაზე ცნობილია ისეთ ადგილები სადაც Target-action მექანიზმი გვჭირდება. Target-action მექანიზმი UIKit ში ისევ Objective-C შია დაწერილი, ამიტომ ნებისმიერ დროს როდესაც მაგალითად ჩვეულებრივ UIButton ს addTarget ფუნქციას იძახებთ. იმისთვის რომ Selector ს გადასცეთ ფუნქცია გიწევთ ფუნქციას წინ ატრიბუტი გაუკეთოთ @objc, რაც იმას ნიშნავს, რომ ამ ფუნქციის გამოძახება მოხდება obj-c runtime ში.

როგორ შეიძლება რომ Swift ში კონკრეტული ფუნქციები objective-c runtime ში გავუშვათ?

ამისთვის ორი ატრიბუტი გვაქვს

  • @objc
  • dynamic

განსხვავება საკმაოდ დიდია, @objc ით მონიშნული ფუნქციები დანახვადი იქნება obj-c runtime ისთვის. ასევე გაეშვება ამავე runtime ში, მაგრამ swift ი ამ ფუნქციებისთვის ან static dispatch ს ან table dispatch ს გამოიყენებს. თუ ამ კონკრეტულ ფუნქციაზე, objective-c ში swizzling ი მოხდება, საერთოდ სხვა შედეგებს მივიღებთ ან შეიძლება ქრაში მივიღოთ.

მეორეს მხრივ dynamic keyword ის გამოყენებით, Swift ს ვეუბნებით რომ ეს კონკრეტული ფუნქცია ყოველთვის Message dispatch ით იქნას გამოყენებული. ეს აუცილებელია როცა Key-value observing ს ვაკეთებთ, ან უბრალოდ როცა გვინდა რომ runtime ში მეტი დინამიურობა გვქონდეს. არ უნდა დაგვავიწყდეს, რომ მსგავის ტიპის dispatch ი performance ის მხრივ ყველაზე ნელია.

მარტივად რომ დავამტკიცოთ dynamic ის Message dispatch ობა, მსგავის მაგალითი შეგვიძლია გავაკეთოთ.

  1. გვაქვს Shape კლასი, რომელსაც აქვს draw ფუნქცია
  2. ვაკეთებთ Shape ის extension ს სადაც ვამატებთ ერთ ფუნქციას redraw
  3. ვქმნით შვილობილ კლასს სადაც ვცდილობთ redraw ის გადატვირთვას

ამ ყველაფრის შემდეგ override func redraw ფუნქციასთან მოგვდის compile-time შეცდომა რომელიც ესე გამოიყურება

რატომ გვაძლევს შეცდომას ? თუ აქამდე არ იცოდით, extension method ები default ად static dispatch ს იყენებენ performance ისთვის, როგორც ზევით ვახსენეთ ისეთი მეთოდები რომელიც სტატიკური დისპაჩით მუშაობენ არ იტვირთებიან, ანუ შეუძლებელია მათზე override ის გაკეთება. თუმცა მოდი ვნახოთ როგორ შეიძლება Message dispatch ით და objective-c ის runtime ის ჩარევით ამ პრობლემის მოგვარება.

როგორც კი ფუნქციას მივუთითებთ, რომ ის objective-c ის runtime ში გვინდა გაეშვას, Swift ის static dispatch ი თმობს ამ ფუნქციაზე მიმთითებელს, და უკვე შეგვიძლია კონკრეტული ფუნქციის შვილობილ კლასებში გადატვირთვა. თუმცა ეს არღვევს ენის კანონებს, რადგან Apple ი გვეუბნება, რომ extension method ები ფუნქციონალის დამატებისთვისაა და არა მემკვიდრეობითი ფუქნციონალის შექმნისთვის. თუმცა ეს მიდგომა გამოსადეგია როცა ძველ და legacy კოდზე ვმუშაობთ და ახალი ფუნქციონალის დამატებისთვის არ გვინდა ძველი კოდის ბევრ ადგილას შეცვლა.

მოდი ეხლა გავიაროთ ყველა ის dispatch modifier ი რომელიც Swift ში გვაქვს, რომ პრაქტიკული სახით შევაჯამოთ დღევანდელი სტატია.

final

final modifier ი swift ს აიძულებს, რომ კონკრეტული ფუნქცია static dispatch ით იქნას გამოძახებული. final modifier ი უდიდეს როლს ასრულებს performance ის გაზრდაში. ეხლა მოდით დაფიქრდით და აღიარეთ, თქვენს პროექტებში რამდენი კლასი გიწერიათ, რომელიც არ იყენებს inheritance ს და რომლის მეთოდებიც არსად არ არის გადატვირთული ? დარწმუნებული ვარ ესეთი ბევრი იქნება.

არადა Swift ი იმის გამო, რომ კლასი reference ტიპია ესეთ მეთოდებს Table dispatch ით ემსახურება, რომელიც ზევით ავხსენით. რა მოხდება თუ კლასს final ად მონიშნავთ? ამით კომპილატორს ეტყვით რომ ჩემს კლასს მომავალში შვილი არ ეყოლება, რაც იმას ნიშნავს რომ ჩემი ფუნქციები არასდროს არ იქნებიან გადატვირთულები, რაც ავტომატურად ნიშნავს ამ ფუნქციების static dispatch ად მომსახურებას, რომელიც ყველაზე სწრაფია.

dynamic

როგორც ზევით ვახენეთ dynamic modifier ი Swift ს უბრძანებს, რომ ეს კონკრეტული ფუნქცია message dispatch ით იქნას დამუშავებული Objective-c ის runtime ში. dynamic ის გამოყენებისთვის მოგიწევთ Foundation ის დაიმპორტება, რაც თავისთავად objective-c ს რანთაიმსაც წამოიღებს თქვენს სამყაროში.

@objc

objc modifier ი არ განაზოგადებს კონკრეტულ dispatch სტრატეგიას, მაგრამ ცალსახად ამბობს რომ კონკრეტული ფუნქცია objective-c ის runtime ში გაეშვას. თუ ამ ფუნქციას extension მეთოდად დავამატებთ, ცალსახად ის static dispatch ს გამოიყენებს, თუ უბრალოდ კლასში დავამატებთ მაშინ Table dispatch ს. აქაც მარტივი Trick and Tip სი იქ იქნება, რომ ღილაკისთვის გამზადებული selector ფუნქციები ყოველთვის extension ებად გავიტანოთ, რადგან არ მოხდეს Table dispatch ის გამოყენება.

@inline

inline ფუნქციები C/C++ ში გამოირჩევიან სისწრაფით და მცირე execution time ით, ჩვენ შეგვიძლია ყველა ფუნქციას წინასწარ გავუწეროთ @inline(always) ან @inline(never) რაც სავსებით ლოგიკურია რასაც ნიშნავს. თუმცა ხშირ შემთხვევაში ეს არ გვჭირდება, რადგან Swift ის კომპაილერი ძალიან ჭკვიანია და თვითონ ხვდება თუ რომელი ფუნქცია გახადოს inline და რომელი არა, ჩვენი ჩარევის გარეშე, ამიტომ შესაბამისად ეს ატრიბუტი არც თუ ისე გამოყენებადია ყოველდღიურ development ში. ასევე Swift 5 ში დაემატა ატრიბუტი @inlinable რომელიც კონკრეტულად Framework ებისთვისაა, რომლებსაც ჩვენ ვქმნით ცალკე მოდულებად. კომპილატორს ჩვენი კოდის დაკომპილირებისას მარტივად შეუძლია მიიღოს გადაწვეტილება ფუნქცია inline გახადოს თუ არა, მაგრამ როდესაც framework ებზეა საუბარი წინასწარ არ ვიცით ჩვენი framework ის გამომყენებელი როგორ გამოიყენებს ჩვენს მიცემულ ფუნქციებს, ამიტომ @inlinable ით წინასწარ შეგვიძლია განვსაზღვროთ რა შეიძლება იყოს inline და რა არა. inline ზე მეტის წაკითხვისთივს ამ ბმულს ეწვიეთ.

არ დაგვავიწყდეს, რომ ყველა ფუნქცია რომელიც Class ში იქნება გამოცხადებული, default ად Table dispatch ს გამოიყენებს.

Conclusion

საბოლო ჯამში, 3 ტიპის dispatch ზე ვისაუბრეთ. მინიმუმ იმას ვეცადე, რომ გაგვეგო თუ რომელი როდის გამოვიყენოთ და რა tradeoff ებთან გვაქვს საქმე. ეს სტატია ასევე შეგიძლიათ გამოიყენოთ coding interview ებზე. თითქმის ყველა გასაუბრებაზე სვავენ კითხვას თუ რა განსხვავებაა class სა და struct ს შორის, თუ ყველა ნაცნობი მაგალითის გარდა static და table dispatch საც ახსენებთ, დამიჯერეთ საკმაოდ მაღლა გამოჩნდებით გამსაუბრებლის თვალში.

თუ სიღრმისეულად გაინტერესებთ უფრო კონკრეტულად რას აკეთებს ჩვენთვის ნაცნობი runtime ები, გირჩევთ რომ SIL ს ჩახედოთ, ნელ ნელა მისი წაკითხვაც უფრო და უფრო გაგიმარტივდებათ.

და ბოლოს, არ დაგავიწყდეთ final keyword ი ისეთ კლასებთან, რომლებიც არასდროს არიან მემკვიდრეობით იერარქიაში. დამიჯერეთ, ერთ და ორი კლასის final ად შეცვლა არ მოგცემთ ისეთ შედეგს, რომ თვალით შეამჩნიოთ მაგრამ როდესაც უზარმაზარი codebase ი გაქვთ, მემკვიდრეობითობის იერარქიის სწორად დალაგება და არა საჭირო კლასებზე final ის მითითება საგრძნობლად დიდ შედეგს მოგიტანთ performance ში.

მადლობა


Swift - რა არის Method dispatch ი? რა ტიპის dispatch ები გვაქვს და როგორ მუშაობენ ისინი. was originally published in ka_GE on Medium, where people are continuing the conversation by highlighting and responding to this story.

]]>
<![CDATA[Swift —რა არის და როგორ მუშაობს Closure-ი ფარდის უკან.]]> https://medium.com/ka-ge/swift-%E1%83%A0%E1%83%90-%E1%83%90%E1%83%A0%E1%83%98%E1%83%A1-%E1%83%93%E1%83%90-%E1%83%A0%E1%83%9D%E1%83%92%E1%83%9D%E1%83%A0-%E1%83%9B%E1%83%A3%E1%83%A8%E1%83%90%E1%83%9D%E1%83%91%E1%83%A1-closure-%E1%83%98-%E1%83%A4%E1%83%90%E1%83%A0%E1%83%93%E1%83%98%E1%83%A1-%E1%83%A3%E1%83%99%E1%83%90%E1%83%9C-98278010d897?source=rss-e061295e669a------2 https://medium.com/p/98278010d897 Sun, 24 Jul 2022 14:57:08 GMT 2022-07-24T14:57:08.543Z Swift —რა არის და როგორ მუშაობს Closure-ი ფარდის უკან. Capturing ის იმპლემენტაცია pointer ების დონეზე 0 იდან.

არსებობს ესეთი მცნება, infinite-regress, რომელიც წარმოადგენს უსასრულო entity ების სერიას, რომლებიც იმართებიან რეკურსიული პრინციპით სადაც თითოეული რეკურსია ასახავს თუ როგორაა თითოეული entity დამოკიდებული ან წარმოქმნილი, მისი წარმომქმნელისადმი. WTF ?

“Turtles all the way down” არის infinite-regress იის გამოხატულება. Hindu mythology აში სჯეროდათ, რომ სამყაროს სიცოცხლეში ეხმარებოდა ჯაჭვივით გადაბმული უზარმაზარი კუ-ების სერია, სადაც თითოეული კუს ქვევით ისევ კუ იყო. ამ ჯაჭვურ რეაქციაში ყველა კუს, სამყაროს მიმართ საკუთარი პასუხისმგებლობა და მისია გააჩნდა. მათ სჯეროდათ, რომ სულ თავში წარმოქმნილ კუზე, რამოდენიმე სპილო იდგა, რომლებზეც არსებობდა მთელი სამყარო. ზემოთ ხსენებული გამოხატულება ხშირად გამოიყენება, ისეთი პრობლემების ილუსტრაციისთვის როგორიც არის regress argument ი epistemology აში.

კარგით, ეხლა რა შუაშია ეს წინასიტყვაობა Closure ებთან ?

კომპიუტერულ მეცნიერების ყველაზე მნიშვნელოვანი ნაწილი ჩემი აზრით აბსტრაქციაა, რომელსაც ყოველდღე ვეჯახებით. ჩვენ ხშირად გვაქვს შეხება სხვადასხვა layer of abstraction თან. ვმუშაობთ ბიბლიოთეკებთან, რომლებიც დაშენებულია უფრო ქვედა დონის აბსტრაქციაზე, რომლებიც სხვა ენაზეა დაწერილი და ბოლოს კი ყველაფერი binary code ად გარდაიქმნება, რომელიც შემდეგ CPU ზე და GPU ზე ეშვება, რაც თავისმხრივ იყენებს ტრანზისტორებს და logic gate ებს.

ხანდახან ვერც კი ვიაზრებთ ჩვენს ქვევით თუ რამხელა აბსტრაქციაა და ზუსტად ამ ჯაჭვური აბსტრაქციებით ვქმნით “Turtles” და infinite-regress ს სადაც თითოეული აბსტრაქცია, ისევ აბსტრაქციაზეა დამოკიდებული. ხოლო ამ აბსტრაქციების ერთობლიობაზე დგას დღეს Software Development ი, და ამ აბსტრაქციების გააზრება ძალიან მნიშვნელოვანია.

ამ უსასრულო აბსტრაქციაში, ზუსტად ერთი პატრა აბსტრაქციაა Closure -ი, რომელზეც დღეს ვისაუბრებთ.

Swift ის ეკოსისტემაში ჩემი ერთ-ერთი საყვარელი feature ი Closure ია. ხელსაწყოები რომლებსაც ყოველდღიურად ვიყენებთ, third-party ბიბლიოთეკები და კიდევ უამრავი სხვა environment ი, რომელიც ჩვენი ყოველდღიურობის ნაწილია იყენებს Closure ებს. ის ერთ-ერთი feature ია ენის, რომელსაც მართლაც ყოველდღე ვიყენებთ, მაგრამ ხშირ შემთხვევაში არ გვესმის როგორ მუშაობს სიღრმისეულად.

აქვე ვეცდები გავიხსენო სად გვხვდება ყველაზე მეტად Closure ები

  1. RxSwift
  2. Combine
  3. DispatchQueue
  4. High order functions
  5. SwiftUI
  6. SnapKit
  7. Alamofire
  8. URLSession

ეს მწირი ჩამონათვალი, საკმარისია იმის გასააზრებლად თუ რამდენად მნიშვნელოვანია Closure ების ცოდნა სიღრმისეულად. ზუსტად ამ მიზეზის გამო გადავწყიტე გაგიზიაროთ ჩემი ცოდნა ამ საკითხთან დაკავშირებით, სიღრმისეულად გავარჩიოთ, განვიხილოთ და ვეცადოთ ჩვენით შევქმნათ რაიმე imaginary პროგრამირების ენაში closure ის ფუნქციონალი და ღრმად ჩავშალოთ მისი აბსტრაქცია.

ინტერნეტ სამყაროში ხშირად მხვდება Closure ის ახსნა მსგავსი განმარტებით

“Closure is just a function which is passed as an argument” — ანუ ეს მხოლოდ ფუნქციაა, რომელიც პარამეტრად გადაეცემა სხვა ფუნქციას. არადა რეალურად სულაც არაა ესე.

როგორ მუშაობს სიღრმისეულად ფუნქცია ? ფუნქციის დაძახების შემდეგ, ფუნქციის მისამართი და არგუმენტები ინახება stack ში, ხოლო return ის შემდეგ stack იდან ამ პარამეტრების POP ი ხდება, LIFO მიდგომით. (თუ Stack არ იცით რა არის, ამ ბმულს ეწვიეთ, ჩემი ძალიან ძველი სტატიაა, მაგრამ ზოგად ცოდნას მოგცემთ) ანუ გამოდის, რომ ჩვენი ფუნქციის შესახებ ყველანაირი ინფორმაცია ერთ stack ში ინახება. თუმცა CPU-მ რომ ჩვენი ფუნქცია გაუშვას, სჭირდება ფუნქციის მისამართი, რომელიც უბრალო ციფრებია 16 ობით სისტემაში. ყველა ფუნქცია, რომელიც იქმნება გამოყოფს მიზერულ მეხსიერებას, სადაც ამ კონკრეტული ფუნქციის მისამართი ინახება რომელიც შეგვიძლია ცვლადში შევინახოთ, ანუ გავაკეთოთ ფუნქციის პოინტერი, რომელიც ფუნქციის მისამართზე მიუთითებს. ლოგიკურად თუ გვაქვს, ცვლადი ეს გვაძლევს საშვალებას რომ სხვა ფუნქციას, პარამეტრის სახითაც ჩავაწოდოთ.

ესე გამოიყურება ზევით მოყვანილი მაგალითის იმპლემენტაცია Swift ში

takesAnotherFunction ს პარამეტრად ზუსტად სხვა ფუნქციის მისამართი გადავეცით, და შემდეგ ამ ფუნქციამ შეძლო მისამართის წყალობით ამავე ფუნქციის გამოძახება.

ხომ არ გახსენდებათ სად ვიყენებთ ამ მიდგომას iOS ში ?

როდესაც გვინდა, რომ ღილაკზე დაჭერისას რაიმე კონკრეტული ფუნქცია გამოვიძახოთ, გვჭირდება რომ შევქმნათ @objc ატრიბუტით ფუნქცია, რომელსაც selector ში ჩავაწვდით.

რატომ ?

UIKit ი როდესაც დააფიქსირებს მის ღილაკზე .touchUpInside ის event ს, მას ამ დროს ექნება ჩვენი ფუნქციის მისამართი და შეძლებს რომ დაუძახოს მას. ამ მექანიზმის დახმარებით ჩვენ შეგვიძლია ამ ღილაკის click ზე ჩვენს საკუთარ ფუნქციაში, ჩვენი საკუთარი ლოგიკა დავწეროთ.

რა მინუსი აქვს ფუნქციის უბრალოდ პარამეტრად ჩაწოდებას ?

ერთი და ყველაზე დიდი მინუსი არის ის, რომ მას არ აქვს წვდომა გარე სამყაროსთან, მისი კონტექსტი მხოლოდ საკუტარი stack frame იდან რეზულტატის დაბრუნებამდეა.

და ზუსტად აქ შემოდის თამაშში Closure ი, რომელიც გვაძლევს საშვალებას გარე სამყაროსთან კავშირი გვქონდეს და ვიყენებდეთ ისე როგორც ფუნქცია, თუმცა ასევე ბევრად უფრო საინტერესო რაღაცეები შეეძლოს ვიდრე უბრალოდ ჩვეულებრივ მეთოდს.

Swift ში იმისთვის, რომ ფუნქციას callback ი გავაყოლოთ ვიყენებთ Closure ს. Closure არის კოდის ბლოკი, რომელსაც საკუთარი ფუნქციონალი გააჩნია. Closure ებია იგივეა რაც მაგალითად block ები C ში, Obj-C ში და lambda ფუნქციები ები სხვა ენებში.

Closure ების ძალა იმალება Capturing ში. Capturing ი არის პროცესი როცა ზემოთ ხსენებულს შეუძლია შეინახოს ყველა reference, კონსტანტები და ჩვეულებრივ ცვლადები, რომელიც არსებობს იმ კონტექსტში სადაც Closure ი შეიქმნა.

ვნახოთ პატარა მაგალითი

ზემოთ მოყვანილ მაგალითში ვხედავთ f closure ს, რომელიც Capturing ს უკეთებს Environment ს სადაც firstVariable ცვლადი არსებობს.

  • Capturing - არის პროცესი როდესაც ინახავს context ში მყოფ ცვლადებს.
  • Environment - Closure ის გარეთ მყოფი context ი, ყველა ცვლადი, რეფერენსი და ობიექტი რომლის Capturing იც შესაძლებელია.

closure ის გამოძახება მხოლოდ მაშინ ხდება, როდესაც f ს დავუძახებთ, რაც იმას ნიშნავს რომ Captured ცვლადი ყოველთვის დინამიური იქნება და ნებისმიერ დროს შეიძლება შეიცვალოს, გამომდინარე აქედან Closure ის პასუხიც ყოველთვის განსხვავებული იქნება.

Closure ებს reference სემანტიკა აქვს და არა value. ანუ Closure ის შექმნისას მონაცემები heap ში გამოიყოფა და არა stack ში, რა მონაცემები ? ყველაფერი რაც Environment ში იქნება, წავა heap ში Closure ის სახით.

ზემოთ ხსენებულიდან გამომდინარე, ხშირია იმის რისკი რომ Closure ბთან მუშაობის დროს გაგვეპაროს Memory leak ი. ამის პრევენციისთვის რამოდენიმე ხერხი არსებობს, თუმცა მანამდე ვისაუბროთ Closure ების ტიპებზე Swift ში.

სულ ორი ტიპის Closure ი გვაქვს.

@nonescaping Closure ები

ესეთია Closure რომელიც სხვა ფუნქციას პარამეტრად გადაეცემა და მისი გამოძახება ამავე function body ში ხდება და რეზულტატიც მაშინვე ბრუნდება. ფუნქციის დასრულების შემდეგ, გადაწოდებული Closure ი out of scope აღმოჩნდება, მისი reference counter ი 0 გახდება და ARC წაშლის მას მეხსიერებიდან.

nonescaping Closure ის სიცოცხლის უნარიანობა ესეთია:
1. გადაეცემა პარამეტრად ფუნქციას (heap ში გამოიყოფა ადგილი)
2. Environment capturing ის აკეთებს და შემდეგ რაიმე ფუნქციონალსაც ასრულებს.
3. ფუნქცია ეძახის Closure ს.
4. ფუნქცია აბრუნებს პარამეტრს, Closure გასცდება scope ს და წაიშლება მეხსიერებიდან.

ამ მაგალითში completionHandler ი nonescaping Closure ია, ატრიბუტის მიწერა საჭირო არ არის, რადგან ყველა Closure ი, რომელიც Environment ფუნქციის დასასრულამდე გამოიძახება default ად არის nonescaping ი.

რომელია Environment ფუნქცია ?

  • getSumOfArray

გამოიძახება completioHandler ი იქამდე სანამ getSumOfArray დასრულდება ?

  • კი

გამოდის, რომ სანერვიულო არაფერი მაქვს, დარწმუნებული ვარ რომ დროებით შექმნილი reference ობიექტი იქამდე წაიშლება სანამ Environment ი ცოცხალი იქნება.

@escaping Closure ები

როდესაც Closure ს პარამეტრად გადავცემთ და ვიცით, რომ function body უფრო მალე დასრულდება და არ ვიცით Closure ი ზუსტად როდის გამოიძახება ესეთ დროს გვიწევს რომ Closure მოვნიშნოთ @escaping ად.

როდესაც ფუნქციის execution ი მორჩება, Closure მაინც იარსებებს მეხსიერებაში იქამდე სანამ მისი გამოძახება არ მოხდება.

როდის გვჭირდება escaping Closure ი?

  • ასინქრონული Execution ის დროს.
  1. როდესაც Closure ს ვატანთ DispatchQueue ს ან ნებისმიერ ისეთ გარემოს, რომელიც რაიმე სახის ოპერაციას უშვებს background thread ზე და შემდეგ აპირებს ჩვენი Closure ის გამოძახებას, რომ შესრულებული სამუშაოს რეზულტატი დაგვიბრუნოს.
  2. ან ჩვენს გადაცემულ Closure ს უშვებს background thread ზე და აქაც არ ვიცით ოპერაცია როდის დასრულდება.

ყველა ესეთ შემთხვევაში Environment ფუნქცია აპრიორში დასრულდება იქამდე სანამ ჩვენი closure ი გამოიძახება, ამიტომ არ ვიცით ეს როდის მოხდება. ასეთ შემთხვევებში Compiler ი სხვადასხვა ოპტიმიზაციებს აკეთებს, აქედან გამომდინარე მას ჭირდება წინასწარ, რომ იცოდეს რომელი Closure ისა escaping და nonescapig ი.

ამ ატრიბუტით ხვდება compiler ი თუ რომელი ოპტიმიზაციით აამუშაოს ჩვენი კოდი.

თუ Closure ს რაიმე ასინქრონულ ოპერაციაში გამოვიყენებთ და მას @escaping ს არ მივუწერთ, Compile time error ი გვექნება.

მაგალითი სადაც escaping closure ის გამოყენებაა საჭირო.

რა პრობლემები შეიძლება გამოიწვიოს Closure ებმა ?

როგორც ავღნიშნეთ Closure ი reference type ია, და მისი მეხსიერება heap ში გამოიყოფა. Capturing ის დროს ყველაფერს ინახავს, მაგალითად თუ შენ self ს გამოიყენებ closure ში, ის self ის strong reference ს შეინახავს მისი სიცოცხლის განმავლობაში. თუ self ს ასევე დაჭირდება რომ შეინახოს closure ის რეფერენსი (იმ შემთხვევაში, თუ მის გამოძახებას მოგვიანებით გეგმავს) მაშინ მათ შორის retain cycle ანუ circular reference ი მოხდება.

retain cycle ი ავტომატურად memory leak ს ნიშნავს.

როგორ უნდა დავიცვათ თავი ? მადლობა Chris lattner ს, რომ გვაქვს weak და unowned keyword ები.

მარტივად რომ ვთქვათ weak და unowned ი ეუბნება ARC ის, რომ კონკრეტული ობიექტის შექმნის შემდეგ reference counter ი არ გაიზარდოს და ის მუდამ 1 იყოს. ეს აღარ გამოიწვევს strong circular reference cycle ს closure სა და self ს შორის. (უფრო კონკრეტულად ARC ზე და memory management ზე, შემდეგ სტატიებში ვისაუბრებ)

განსხვავება weak სა და unowned შორის ძალიან მცირეა

  1. weak ი ავტომატურად შეფუთავს self ს optional ში, რაც საჭიროა მეტი უსაფრთხოებისთვის.
  2. unowned ი ისე მოიქცევა როგორც force unwrap ი, რაც საკმაოდ სახიფათოა.

ზუსტად ვიცით, რომ ამ შემთხვევაში memory leak ისგან დაზღვეულები ვართ, თუმცა ამას შეიძლება კიდევ ახალი პრობლემა მოყვეს.

წარმოვიდგინოთ ესეთი შემთხვევა, რომ Closure ი ფუნქციაში ასინქრონული ოპერაციის იქნება გამოძახებული, მაგრამ არავინ ვიცით ეს ოპერაცია როდის მოხდება. სავსებით შესაძლებელია, როდესაც ეს ოპერაცია მოხდება self ი მთლიანად წაშლილი იყოს memory დან, ხოლო ჩვენი closure ის body იყენებს ამ self ს და მისი მთელი Environment ის Capturing ი აქვს გაკეთებული.

ასეთ შემთხვევაში აპრიორი ქრაში გვაქვს თუ:

  1. არ ვიყენებთ პირდაპირ self ს შემოწმების გარეშე.
  2. ვიყენებთ unowned self ს
  3. ან ვიყენებთ weak self ს, მაგრამ self ის unwrap ს force ით ვაკეთებთ.

საუკეთესო გამოსავალია guard ის გამოყენება ან optional chaining ოპერატორის გამოყენება > ?

ქვევით მოყვანილ მაგალითში ყველანაირად დაზღევულები ვართ

  1. memory leak ისგან
  2. nil pointer exception ისგან

თუ self ი მკვდარი იქნება, closure იდან გამოსვლა პირველივე ხაზზე მოხდება.

ხოლო cycle reference არ მოხდება მათ შორის რადგან closure ში პარამეტრად weak self ი შემოდის და არა strong ი.

guard ით შექმნილი strongSelf ი, რომელიც მაშინვე წაიშლება როგორც კი closure ი მორჩება executions

Closure - ის იმპლემენტაცია lower level ში

მემგონი საკმარისზე მეტი ვისაუბრე Closure ების მუშაობის პრინციპზე, მაგრამ მაინც მგონია, რომ ყველაზე ნათლად მაშინ ვიგებთ აბსტრაქციას, როდესაც ჩვენითვე ვქმნით მათ.

Closure ების შექმნა ორ აზროვანია და მოდით ესე გავანაწილოთ.

  1. პირველ ნაწილში შევქმნათ ძალიან აბსტრაქტულად და მარტივად, ამას Easy part დავარქვათ.
  2. მეორე ნაწილში, ნამდვილად გავიმეოროთ ის რაც Memory ში ხდება, Capturing ის დროს და კომპლექსური იმპლემენტაცია გავაკეთოთ, ამას კი Complex part

Closure ის იმპლემენტაცია, Easy part

წარმოვიდგინოთ, რომ რაღაც წარმოდგენით ენაში ვმოღვაწეობთ სადაც Closure ი ტექნიკურად არ არსებობს.

  1. ფუნქციებს არ შეუძლიათ Capturing ი.
  2. არ იციან მათი Environment ი რა არის.
  3. ფუნქციის პარამეტრად გადაწოდება, მხოლოდ სტანდარტული მიდგომით შეიძლება, სადაც ფუნქციამ მხოლოდ საკუთარი context ი იცის.

(იმისთვის, რომ Closure ის აბსტრაქცია შევქმნათ, პრე-რეკვიზიტად არ გვჭირდება Compiler ის შესახებ რაიმეს ცოდნა)

ზემოთ აღწერილ ენაში, წარმოვიდგინოთ ესეთი კოდი.

წარმოდგენით ენაში ვართ, სადაც closure ები არ გვაქვს.

ასეთ შემთხვევაში compile ერორი გვექნება.

კომპილატორი გვეტყვის, რომ f მა არ იცის number ი რა არის, რადგან არ გვაქვს Capturing ი. Capturing ი იმიტომ ვერ მოხდა, რომ არ გვაქვს Environment და არ ვიცით ჩვენი კონტექსტი რა არის.

Closure ების იმპლემენტაციისთვის რა თქმა უნდა, რომ დაგვჭირდება class ები, რომ მივიღოთ reference type ი. ასევე გამოსადეგი იქნება თუ Closure ს ჯერ ავღწერთ როგორც პროტოკოლს, რადგან ის აბსტრაქციად წარმოვიდგინოთ.

Closure პროტოკოლი

მოდით ეხლა ამ Closure პროტოკოლის იმპლემენტაცია დავწეროთ, კონკრეტულ კლასში.

F კლასი, რომელიც იმპლემენტაციას უკეთებს Closure პროტოკოლს.
  • F ი Environment ის კლასს ქმნის, სადაც შიგნით state ს ინახავს, ამ შემთხვევაში Int ცვლადს
  • F ს env ცვლადი აქვს, რომელიც Environment ტიპისაა
  • ინიციალიზატორით ხდება Environment ის injection ი კლასში.
  • გვაქვს willRun ფუნქცია, რომელიც პარამეტრად იღებს x ს ხოლო აბრუნებს x +environment ში შენახული ინტეჯერი ანუ x + env.a

ჩვენ უკვე ჩვენი საკუთარი Closure ი შევმქენით, რომელიც envrionment ს ქმნის, მის Capturing ს ანხორციელებს და რაღაც კონკრეტულ ფუნქციას იძახებს. რა თქმა უნდა ჩვენს Closure ს მხოლოდ Int ის შენახვა შეუძლია, მხოლოდ ერთის და ძალიან მწირი ფუნქციონალი აქვს, მაგრამ სასწავლო გარემოსთვის სავსებით საკმარისია.

როგორ გამოვიყენოთ ?

ჩვენივე შექმნილი Closure ის, წარმოდგენითი გამოყენება.

რა თქმა უნდა ნამდვილ Closure ს არ გავს, რომელსაც Swift ში ვიყენებთ, მაგრამ პრინციპი ერთი და იგივეა.

  1. ისეტავს Environment ს რომელიც მის გარშემო არსებობს.
  2. Capturing ს უკეთებს ველა ინფორმაციას, რომელიც Environment ში არსებობს.
  3. ასრულებს რაღაც ბრძანებას, სადაც Environment ი ყოველთვის დინამიურია და ნებისმიერ დროს შეიძლება შეიცვალოს.

დაახლოებით ესეთ პეტერნს იყენებდა C++ იქამდე სანამ C++11 ში lambda არ დაამატეს. თუ რომელიმე სხვა ენაში ტერმინი ანონიმურ ფუნქციები მოგისმენია, შეგიძლია წარმოიდგინო რომ Closure ი ამის syntactic sugar ია.

Closure ის იმპლემენტაცია, Complex part

ფარდის უკან რეალურად, closure ისთვის კომპილატორი ბევრად უფრო კომპლექსურ და რთულ პრობლემებს აგვარებს.

მოდი ვცადოთ და წოტა უფრო ჩავშალოთ აბსტრაქციას და უფრო კომპლექსურად, რეალობასთან მიახლოებული Closure ის იმპლემენტაცია გავაკეთოთ.

მაგალითისთვის წარმოვიდგინოთ, რომ გვაქვს Closure ი, რომელმაც მხოლოდ 1 value ს Capturing ი უნდა მოახდინოს.

Single value capturing ი Box სტრუქტურის სახით.

მოდით ეხლა შევამოწმოთ, რამდენად მოერგება ჩვენი დაწერილი აბსტრაქცია რეალურ გამოყენებას და შევქმნათ კიდევ ერთი აბსტრაქცია.

სიმულაცია ჩვენი შექმნილი ThickFunction ის თუ როგორ აკეთებს რეალურად Capturing ს გარე Environment ის და ამ დროს რა ხდება რეალურად Memory ში.

ზემოთ მოყვანილ კოდის მაგალითს თუ კარგად წაიკითხავთ, ნახავთ რომ ჩვენი აწყობილი აბსტრაქცია მშვენივრად მუშაობს. ზუსტად გამოყოფთ memory ში ადგილს და Capturing ი სადაც მხოლოდ ერთი value ს შენახვა გვინდა მშვენივრად მუშაობს.

სიმულაციური Closure ისთვის ზუსტად აფდეითდება Box ში შენახული გარე სამყაროს ცვლადი.

კოდს თუ კარგად დააკვირდით ყველა ხაზი დაკომენტირებულია გარდა ერთისა, რომელიც ყველაზე მნიშვნელოვან საქმეს, Capturing ს აკეთებს 1 memory დან მეორეზე.

withMemoryRebound ს შეუძლია გაუშვას გადაწოდებული closure ი, საკუთარ კონტექსტში ისე რომ ThickFunction ში გამოყოფილი პოინტერის მისამართს binding ი გაუკეთოს და საკუთარად წარმოიდგინოს. ზუსტად ესე ხდება Capturing ი ფარდის უკან. ერთი Environment იდან მეორე მემორიში ინფორმაციის შენახვას ჯერ ჭირდება ორი მეხსიერების binding ი, ხოლო შემდეგ შესაძლებელი ხდება მეხსიერების გამოყოფა და შენახვა ერთი ადგილიდან მეორეში.

კარგი ეხლა გავიაზროთ binding ი რა არის ?

C/C++ ში memory ობიექტი მეხსიერების ისეთ ნაწილს ეწოდება რომელში ჩაწერილი მნიშვნელობა რაღაც ცვლადში წერია. C/C++ის შემთხვევაში ცვლადის შექმნა თავისთავად ტიპის მინიჭებასაც გულისხმობს, იმიტო რო staticly typed ენაა. უფრო დეტალურად, მეხსიერების რაღაც მონაკვეთში წერია ბაიტები და ამ ბაიტების ინტერპრეტირებისთვის საჭიროა მონაცემის ტიპის ცოდნა. ზუსტად ერთიდაიგივე ბიტების მწკრივი შეიძლება ორ სხვადასხვა რაღაცას ნიშნავდეს იმის მიხედვით თუ რა ტიპში ჩაწერ. თან, ტიპის ცოდნის გარეშე ოპერაციას ვერ ჩაატარებ მაგ მონაზემზე.

ანუ, C/C++ში ობიექტს აქვს ორი რაღაცა: ბაიტები და ტიპი. ბაიტები რო value იცოდეს კომპილერმა და ტიპი რო ამ value-ზე ოპერაციები ჩაატაროს.

binding ის საშვალებით დინამიურად(ტიპის მითითების გარეშე) შეგიძლია რამე ცვლადის შექმნა, binding ის გარეშე ამას ვერ ვიზავთ, რადგან რაღაც ოპერაციამდე(თუნდაც მიმატებამდე) აუცილებელია რო ტიპი დადგინდეს.

ხოდა, swift-ში ამ სისტემური პროცედურის ჩატარებას binding ს ვეძახით, და ეს ფუნქციაც ზუსტად ამას აკეთებს ქვედა დონეზე.

ეს მაგალითი მხოლოდ მოერგება ისეთ Capturing ს, სადაც 1 value გვაქვს, რეალურად მრავალ value იან Capturing ში ბევრად უფრო რთული იმპლემენტაციები ხდება და თუ ამ კომპლექსურობას ზემოდან დავუმატებთ Generic ებს მაშინ ერთი-ორად იზრდება სირთულე, ამ ყველაფრის იმპლემენტაციისთვის.

კონცეპტუალურად ბოლო საკითხი, ყველაზე რთული გასაგებია, თუ გაინტერესებთ, გირჩევთ swift ის mailing list ი გამოიწეროთ, ბევრ საინტერესო საკითხს წაიკითხავთ და საინტერესო დისკუსიებს დაესწრებით. დასაწყისისთვის კი ქვევით მოცემულ ბმულს ეწვიეთ, რომ უკეთესად გაიგოთ რას აკეთებს withMemoryRebound ფუნქცია.

[Pitch] Expand usability of `withMemoryRebound`

საოცარია, რამდენად დიდი და ღრმა აბსტრაქციები იმალება მინიმალისტური ფუნქციონალის უკან, რომელსაც მართლა ყოველდღიურად ვიყენებთ მაგრამ ბოლოდე მაინც არ გვესმის როგორ მუშაობს ის და რამდენ შრომას და ცოდნავს მოითხოვს იმ კომფორტის შექმნა, რომელიც ჩვენთვის არის შექმნილი.

მადლობა ჩემ მეგობარს Amiko Malania -ს დახმარებისთვის და დეტალების დაზუსტებისთივს, როდესაც რამეს ვერ ვიგებ system ურ დონეზე, მასთან გადავამოწმებ ხოლმე ყველაფერს.

იმედი მაქვს მინიმალურად მაინც დაგეხმარებათ ჩემი სტატია, პროგრამირების ამ ფანტაზიურ სამყაროში 🚀


Swift —რა არის და როგორ მუშაობს Closure-ი ფარდის უკან. was originally published in ka_GE on Medium, where people are continuing the conversation by highlighting and responding to this story.

]]>
<![CDATA[Get back to the basics — Process ები, CPU Virtualization, IPC და Thread ები A.K.A ნაკადები]]> https://medium.com/ka-ge/get-back-to-the-basics-process-%E1%83%94%E1%83%91%E1%83%98-cpu-virtualization-ipc-%E1%83%93%E1%83%90-thread-%E1%83%94%E1%83%91%E1%83%98-a-k-a-%E1%83%9C%E1%83%90%E1%83%99%E1%83%90%E1%83%93%E1%83%94%E1%83%91%E1%83%98-892c5ac8978d?source=rss-e061295e669a------2 https://medium.com/p/892c5ac8978d Thu, 21 Jul 2022 19:27:38 GMT 2022-07-21T19:27:38.005Z Get back to the basics — Process ები, CPU Virtualization, IPC და Thread ები A.K.A ნაკადები

შესავალი

იმის მიუხედავად, რომ ჩემი ყოველდღიური Software Engineer ის ცხოვრება iOS სამყაროში მიმდინარეობს, ოდითგანვე მაინტერესებდა სისტემური პროგრამირება და ოპერაციული სისტემები, ხოლო ბოლო თვეების განმავლობაში ჩემს პროექტებს არსებითად დიდი კავშირი აქვთ low-level თან და ფუნდამენტალურ საკითხებთან, რის გამოც მომიწია ბევრი საინტერესო საკითხების წაკითხვა, გაცნობა და შესწავლა. შემდეგ დავაკვირდი, რომ საკმაოდ კარგი კლას-გარეშე სავარჯიშო იყო, რისი მეშვეობითაც ჩემს domain ში ბევრად უფრო პროდუქტიული და თავდაჯერებულიც ვხდებოდი.

დეველოპერები ყოველდღიურად ვსხედვართ ჩვენს კომპიუტერებთან, framework ებთან და ვიყენებთ ხელსაწყოებს, რომლებიც არ გვესმის როგორ მუშაობს. ვსწავლობთ მხოლოდ აბსტრაქციებს, მაღალი დონის ხელსაწყოებს და ხანდახან ეს ძალიან გვაშორებს რეალობისგან, რომელიც ძალიან საინტერესოა.

კონკურენტ-უნარიან სამყაროში ხშირად ვხედავთ რომ არსებობენ top level და avarage level დეველოპერები. მე მჯერა, რომ ფუნდამენტალური საკითხების, თუნდაც ზედაპირულად high-level პერსპექტივიდან ცოდნა ბევრად უკეთეს დეველოპერებად გვაყალიბებს ჩვენს ყოველდღიურ საქმიანობაში, რომლებიც ასე გვიყვარს და გვაინტერესებს.

პროგრამირების, სისტემების, ჩვენი საკუთარი framework ების და პლატფორმების ფუნდამენტალური ცოდნა, გაგება თუ როგორ მუშაობს ყველაფერი ფარდის უკან, მჯერა რომ ერთ-ერთი უმთავრესი skill ია დღეს იმ ბაზარზე, სადაც ვმოღვაწეობთ.

სტატიაში გაგიზიარებთ ჩემს მწირ ცოდნას Process, CPU Virtualization, IPC და User/Kernel level Thread ებზე.

Process

Process — ი უნდა წარმოვიდგინოთ, რომ არის ისეთი dynamic entity, რომელიც სხვადასხვა დავალებას ასრულებს ოპერაციულ სისტემაში და ასევე ცვალებადია მისი მიმდინარეობის დროს.

ხშირად პროგრამა და პროცესი სინონიმები გვგონია, თუმცა ესე არაა. პროგრამა არის მანქანური კოდის ინსტრუქცია, რომელიც დისკზე გვაქვს შენახული და პასიური entity ია. როდესაც პროგრამას ჩვენს სისტემაზე ვუშვებთ, იქმნება პროცესი რომელიც ამ კონკრეტული პროგრამის execution ის მიმდინარეობაა.

პროგრამა როგორც ასეთი უსიცოცხლო ობიექტია, რომელიც დისკზე ცოცხლობს. როდესაც მას ვუშვებთ ჩვენი OS ი აკეთებს ინიციალიზაციას ახალი პროცესის, რომელიც ჩვენს არჩეულ პროგრამას უშვებს პროცესის სახით.

Process-ს ყოველთვის ყავს Parent process ი და შესაძლებელია ყავდეს ასევე child process ი. ყველა child პროცესი იქმნება parent პროცესიდან გამომდინარე, ანუ ოპერაციული სისტემა ქმნის ახალ პროცეს parent პროცესიდან აკოპირებს მთლიან სტეიტს და ანიჭებს ახალ PID(process identifier) ს.

Linux ის სისტემაში მთავარი პროცესი არის init-ი, ხოლო სხვა ყველა დანარჩენი არის init ის შვილობილი პროცესები.

init პროცესი და მისი child პროცესები Linux ის გარემოში

პროცესები Circular Double linked list ში ინახება. Linked list ის root ი რა თქმა უნდა იქნება init პროცესი, რომელიც kernel ში task_struct ის მონაცემთა სტრუქტურის სახით გვხვდება.

მცირე ოდენი task_struct ის კოდი, კერნელიდან ./linux/include/linux/sched.h

მნიშვნელოვანია ასევე ვისაუბროთ Process ების სტეიტებზე.

Running

პროცესი ან არის გაშვებულ რეჟიმში ან ელოდება გაშვებას. Running სტეიტი ზუსტად ამას გვეუბნება, პროცესი ლოდინის რეჟიმშია თუ უკვე გაშვებულია და იყენებს hardware ის რესურსს.

Waiting

ამ სტეიტში პროცესი ელოდება რაიმე event ს ან რაიმე resource ის გამოყოფას სანამ გაეშვება. Linux ის სამყაროში waiting process ებს ორ ტიპად ყოფენ.

interruptible

uninterruptible

interruptible ტიპის waiting process ი შეიძლება მართული იყოს სხვადასხვა სიგნალების მიერ, მეორეს მხრივ uninterruptible პროცესი პირდაპირ დამოკიდებულია hardware ის სხვადასხვა condition ზე, რაც შეუძლებელს ხდის მის შეჩერებას ან რაიმე სხვა გზის გავლენის მოხდენას.

Stopped

პროცესი გაჩერდა, რაიმე სიგნალის შემდეგ. მაგალითისთვის, როდესაც თქვენს დაწერილ პროგრამებს ადებაგებთ, თქვენი არსებული პროგრამა, რომელიც სისტემაში პროცესადაა გაშვებული Stopped state ში გადადის, და ელოდება სიგნალს რომ ისევ Running state ში გადავიდეს.

Zombie

Zombie პროცესებს ისეთ პროცესებს ვეძახით, რომელიც რაღაცა მიზეზის გამო ისევ არიან ჯერ task_struct ის Linked list ში, მაგრამ მკვდარი პროცესია და არანაირ რესურსს არ იყენებს hardware ის.

ეს ყველა state ყველაზე მეტად პროცესების scheduler ს ჭირდება, რომ სამართლიანად გადაწყვიტოს თუ რომელ process ი იმსახურებს სისტემაში გაშვებას და ყურადღების მიქცევას.

ასევე ყველა პროცესს აქვს უნიკალური identifier ი, რომელსაც pid(process identifier)’ს ვეძახით. მაგალითად ჩემს Macbook ში რომ htop გავუშვა ტერმინალიდან, ესეთ რაღაცას ვნახავთ.

ჩემს ოპერაციულ სისტემაში მიმდინარე პროცესები და მათი PID ები.

ჩემს screenshot ს თუ დავაკვირდებით ბევრ საინტერესო დეტალს შევამჩნევთ.

  1. სისტემაში გაშვებულ პროცესებს
  2. PID ები, რომელიც უნიკალურია თითოეული პროცესისთვის
  3. პროცესის owner ი

და სხვადასხვა მეტა ინფორმაცია memory ზე და CPU ზე.

ყველა პროცესი ერთმანეთისგან პარალელურად მუშაობს და ერთმანეთს ხელს არაფერში არ უშლის.

ასევე საინტერესოა ის ნაწილი სადაც უნდა ვისაუბროთ Memory sharing ზე. პროცესებს საკუთარი თავი ოპერაციული სისტემის ერთადერთი მესაკუთრეები გონიათ, რადგან თითოეული პროცესისთვის გამოიყოფა ცალკე address space — ი, ეს გვაძლევს საშვალებას, რომ თითოეულ პროცესს ქონდეს საკუთარი stack ი და საკუთარი heap ი და I/O (თუ ჯერ არ იცით რაზე გვაქვს საუბარი ამ ბმულს ეწვიეთ დასაწყისისთვის გეყოფათ)

როგორც დასაწყისში ვახსენეთ, პროცესი წარმოადგენს გაშვებულ პროგრამას. იმ მოწყობილებებზე, რომლებსაც ყოველდღიურად ვიყენებთ გაშვებული გვაქვს უამრავი პროცესი პარალელურად, და ისინი იდეალურად მუშაობენ კონკურენტულ გარემოში.

მაგრამ როგორ? ჩვენ ხომ ვიცით რომ CPU’ს რესურსები ულიმიტო არ არის და ის ჩვენ ლიმიტირებულად გვაქვს ინსტრუქტციების შესასრულებლად. რეალურად კი CPU ს რესურსები განაწილებულია ყველა პროცესისთვის CPU virtualization ის წყალობით.

CPU Virtualization

CPU virtualization ი კონცეპტუალურად არის პროცესი სადაც იქმნება ილუზია რომ ჩვენ გვაქვს ბევრი CPU იმ ფაქტის გამორიცხვით რომ ჩვენს მანქანებს მხოლოდ რამოდენიმე აქვთ. თანამედროვე კომპიუტერულ მეცნიერებაში მრავალი ხერხი არსებობს ზემოთ ხსენებულის იმპლემენტაციისთვის. ფუნდამენტალური ტექნიკა ამისთვის არის CPU time sharing.

პრიორიტეტების გადანაწილებას და CPU რესურსის გადანაწილებას სხვადასხვა პროცესებზე OS Scheduler ი წყვიტავს.

time-sharing ი კი არის სიტუაცია, როდესაც process ებს კონკრეტული დრო ეძლევათ რესურსების გამოყენებისთვის. ყველა პროცესი CPU ს რესურსს რაღაც დროის მონაკვეთით იღებს. Scheduler ი უფლებას აძლევს პროცესს რომ X დროის მონაკვეთში გამოიყენოს რესურსები, ამ დროს quantum ს ან time slice ს ვეძახით. თუ პროცეს’ს საკუთარი quantum ი ამოეწურება უკან ბრუნდება ready queue ში და სტეიტიც ეცვლება, ხოლო მის ადგილას ახალი პროცესი მოდის და გადადის running state ში.

Quantum ის გამოთვლა საკმაოდ რთული პროცესია და ხდება ძალიან ფრთხილად, ყოველთვის როცა ოპერაციული სისტემა Scheduling გადაწყვეტილებას იღებს თვითონ Scheduler ი იყენებს პროცესორს. როდესაც სისტემა რამოდენიმე პროცესს ემსახურება Scheduler ს ჭირდება system processing time ი რომ საკუთარი computation ზე იმუშაოს და გადაწყვიტოს თუ რომელი პროცესი გაუშვას და შემდეგ შეუცვალოს მათ სტეიტები. ამ ოპერაციას context switching ი ეწოდება. დროის მონაკვეთს, რომელიც scheduler ს ჭირდება scheduling overhead ი.

თუ გამოთვლილი quanta ზედმეტად მცირეა, ოპერაციულ სისტემას მეთი scheduling ოპერაციები დასჭირდება და უფრო ხშირად, რაც ნიშნავს რომ მთლიანი სისტემის პროცესინგის და რესურსის overhead ი მოხდება. მეორეს მხრივ თუ quanta ძალიან დიდია, მაშინ სხვა პროცესები დიდხანს იცდიან ready queue ში და ამ დროს არსებობს რისკი რომ system ის მომხმარებელმა შეამჩნიოს მწირი რესფონსიულობა.

იმისთვის, რომ პროცესებზე საუბარი რაღაც მხრივ ამოვწუროთ საჭროა ასევე ვისაუბროთ თუ როგორ ამყარებენ კომუნიკაციას ერთმანეთს შორის პროცესები და რა გზები არსებობს ამის მისაღწევად.

Inter-process Communication (IPC)

სხვადასხვა პროცესები, რომლებიც გაშვებულია ერთ მანქანაზე იყენებენ IPC ის ერთმანეთთან საკომუნიკაციოთ.

დღეს სხვადასხვა IPC მექანიზმი არსებობს, მაგრამ ფუნდამენტალურად ყველა იყენებს shared memory ს.

OS ი ალოკაციას უკეთებს რაღაც ოდენობის memory ს სპეციალურად IPC კომუნიკაციისთვის. OS ის და მანქანის ტიპის მიხედვით ამ პროცესს სპეციალური იმპლემენტაცია ჭირდება, რომ shared memory წვდომადი იყოს პროცესებისთვის, რომელიც შესაძლოა სხვადასხვა core ზე ან CPU ზე იყვნენ გაშვებულები.

როდესაც 1 პროცესს უნდა მეორესთვის ინფრომაციის გაგზავნა ან მიღება, ის ეძახის ოპერაციულ სისტემას, რომელიც რაღაც ტიპის LOCK ს აკეთებს კონკრეტულ მემორიზე და შემდეგ ახორციელებს read/write ს. Lock ი პრევენციას უკეთებს კონკურენტულ გარემოში 1 რესურსზე — 2 ან მეტ პარალელურ წვდომას, საწინააღმდეგო შემთხვევაში შესაძლებელია მოხდეს data corruption ი.

მაგალითად Linux ზე ზემოთ აღწერილი პროცესების რეალიზაციისთვის იყენებენ pipe-ებს. Pipe არის high-level მექანიზმი, რომელიც ზემოთ აღწერილს აბსტრაქციას აკეთებს, ის ჩვეულებრივი command ია და პროცესების საკომუნიკაციოდ გამოიყენება. ჩემთვის ცნობილი სულ 2 ტიპის pipe არის.

  • named
  • unnamed

named pipe ები FIFO პრინციპით მუშაობენ (First in, first out), ესეთი ტიპის pipe ებს შეუძლიათ ინფორმაცია გაცვალონ ისეთ პროცესებს შორის, რომლებიც სხვადასხვა core ზე ან CPU ზე ცოცხლობენ.

unnamed pipe ები კი მხოლოდ მაშინ გამოიყენება, როცა process ები მჭიდროდ არიან ერთმანეთთან დაკავშრებულები, ანუ იყენებენ ერთ CPU ს და რესურსს ზემოთ ხსენებული time sharing ით ინაწილებენ.

Pipe ის შექმნის დროს სისტემა ქმნის file descriptor ს ფაილურ სისტემაში, რომელიც დაკავშირებულია local socket თან. socket ის ერთ მხარეს ინფორმაციის ჩაწერისას მონაცემის კოპირება ხდება memory buffer ში ხოლო ამის შემდეგ სისტემა სიგნალს უგზავნის ყველა ისეთ პროცესს, რომელიც ელოდება ინფორმაციის წაკითხვას ამ სოკეტიდან.

ინფორმაციის მიმოცვლის დროს უკან რა თქმა უნდა უამრავი კომპლექსური დეტალი და ალგორითმი იმალება, მაგრამ ფუნდამენტალურად მაინც ყველაფერი დადის shared memory დან წაკითხვა ან მასში ჩაწერაზე.

რაღაც მხრივ ალბათ ამოვწურეთ ძალიან აბსტრაქტულად process ებზე საუბარი და ეხლა დროა Thread ებზე ანუ ნაკადებზე გადავიდეთ რომელსაც high-level დეველოპერები ასე თუ ისე ვიცნობთ და ვიყენებთ ყოველდღიურად.

Threads A.K.A ნაკადები

იმისთვის, რომ thread ებზე ფუნდამენტალურად ვისაუბროთ, აუცილებელია ოდნავ მაინც გვესმოდეს Process ები, ზუსტად ამის გამო დავიწყე ჩემი სტატია პროცესებზე საუბრით.

Thread ი ერთი პროცესის execution unit ია, რომელიც პროცესის გარეშე ვერ იარსებებს. ერთ პროცესს შეიძლება ქონდეს მრავალი სხვადასხვა thread ი, რომელიც ეხმარება პროცეს პარალელიზმში. პარალელიზმში იგულისხმება სხვადასხვა მანქანური ინსტრუქციის კონკურენტ უნარიან, პარალელურ გარემოში გაშვებას.

Thread ს lightweight პროცესსაც კი ეძახიან. იდეა, როგორც უკვე ვთქვით პარალელიზმია, რომელიც process რამოდენიმე thread ებად დაანაწილებს. მაგალითად შეგვიძლია browser ი ავიღოთ, რომელიც ოპერაციულ სისტემაში process ად არის გაშვებული, მაგრამ თითოეული Tab ი შეიძლება იყოს სხვადასხვა thread ი. რა თქმა უნდა ზოგადად ვსაუბრობ, რეალობაში შეიძლება ყველა browser ი, სხვადასხვანაირად იქცეოდეს და სულაც არ იყენებდეს thread ებს და ყველა TAB ი ცალკეულად გაშვებული child პროცესი იყოს.

ფუნდამენტალური იდეა კი რა თქმა უნდა ის არის, რომ პროცესად გაშვებული პროგრამის ინსტრუქციები მუდამ ერთმანეთს არ ელოდებოდნენ თავიანთი რიგისთვის და ერთ პროგრამას შეეძლოს პარალელურად ერთზე მეტი ოპერაციის შესრულება.

მაგალითად

  • დაუკავშირდეს web service ს პარალალურად, სანამ user ი იყენებს აპლიკაციის სხვა ნაწიელბს.
  • წაიკითხოს მონაცემები ლოკალური ბაზიდან ან ჩაწეროს პარალელურად.
  • შეასრულოს რაიმე მძიმე გამოთვლით ოპერაცია პარალელურიად.
  • შეასრულოს Input / Output ოპერაციები დისკზე პარალელურად.
  • პარალელურ რეჟიმში დაუკავშირდეს რამოდენიმე web service ს და არა რიგ-რიგობით.

კიდევ სხვა მრავალი მაგალითი შეგვიძლია მოვიყვანოთ thread ებისთვის, თუმცა ვფიქრობ საკმარისია.

და მაინც რა განსხვავებაა Thread სა და Process შორის ?

ფუნდამენტალური სხვაობა არის, რომ thread ებს, რომლებიც ერთ პროცესში არიან აქვთ ერთი shared memory space ი, ხოლო პროცესებს როგორც ზევით ვახსენე აქვთ სხვადასხვა. Thread ები არ არიან დამოუკიდებლები, როგორც პროცესები. Thread ები იზიარებენ სხვა thread ებთან კოდის სექციებს, მონაცემებს და OS ის რესურსებს. მაგრამ thread ებს ასევე აქვთ საკუთარი program counter(PC), რეგისტრების სეტი და stack space ი.

რა არის program counter ი ან რეგისტრები?

იმისთვის, რომ ინსტრუქციები გაიცვალოს პარალელურ thread ებს შორის, გვჭირდება რომ execution state ი შევინახოთ სადმე. state ის შენახვა კი ზუსტად program counter ებში და რეგისტრებში ხდება. PC გვეუბნება თუ რომელი ბრძანება უნდა შესრულდეს thread ების დამერჯვის შემდეგ პირველი და საიდან უნდა გავაგრძელოთ სვლა, ხოლო CPU ს რეგისტრები ინახავენ სხვადასხვა არგუმენტებს კონკრეტული execution ისთვის.

Thread ებს რამოდენიმე უპირატესობაც კი აქვტProcess ებთან შედარებით

  • სწრაფი Context switch ი. როგორც ზევით ვახსენეთ Scheduler ს პროცესებთან სამუშაოდ ჭირდება, რომ გამოიყენოს CPU ს time-sharing ი, რაც ლოგიკურად მეტ overhead ს აჩენს. ხოლო thread ების შემთხვევაში კონტექსტების შეცვლა ბევრად უფრო მარტივია და სწრაფი.
  • ეფექტური გამოყენება სხვადასხვა პროცესორიან სისტემებში. თუ ჩვენ გვაქვს სხვადასხვა thread ი ერთ პროცესში, ჩვენ შეგვიძლია thread ები სხვადასხვა პროცესორზე გავუშვათ, რაც ლოგიკურად პროცესის სწრაფ execution ს მოგვცემს.
  • რესურსების გაზიარება ბევრად უფრო სწრაფია და მარტივი thread ებს შორის და არ გვჭირდება რომ გამოვიყენოთ IPC ი.

Thread ები შეგვიძლია 2 ნაწილად დავყოთ

  • User level thread
  • Kernel level thread
  1. User level thread ი მაღალი დონის აბსტრაქციიდან იქმნება, რაშიც კერნელი არ ერევა. ამ thread ების მენეჯმენტიც high-level იდან ხდება, თუმცა მაინც ჭირდება kernel ის sys-call ი (system call). User level thread ები ბევრად უფრო სწრაფები არიან და მათი management იც მარტივია. Context-switching ი კი ბევრად სწრაფი. ყველაზე მთავარი კი ის არის, რომ მათი გაშვება ნებისმიერ ოპერაციულ სისტემაზე შეიძლება ვირტუალური გარემოს მეშვეობით.

2. Kernel level thread ებს მთლიანად OS ამენეჯმენტებს. Scheduling იც სხვა დანარჩენიც kernel ის პასუხისმგებლობაა. თუმცა User level thread ებთან განსხვავებით ისინი ბევრად ნელები არიან, management overhead ის გამო. Context switching ი ბევრად უფრო მეტ საფეხურს მოიცავს ვიდრე უბრალოდ სტეიტის შენახვა სხვადასხვა რეგისტრში და PC ში.

მაგალითად, თითქმის ყველა mainstream თანამედროვე ენას აქვს thread ებთან სამუშაოდ თავიანთი გარემო, აბსტრაქცია.

  • C# Tasks
  • Swift DispatchQueue, Tasks
  • Kotlin coroutines
  • Go goroutines

თითქმის ყველა ზემოთ ხსენებული (თუ არ ვცდები), იყენებენ Windows ის ენაზე green-thread ებს, ხოლო UNIX ის ენაზე user level thread ებს, და ქვედა დონეზე წყვეტენ როდის უნდა შეიქმნას kernel level thread ი და როდის user level ი, ყოველდღიურ დეველოპმენტში ჩვენ დეველოპერებს ამაზე ფიქრი არ გვიწევს და რეალურად ყოველდღიური რუტინული დავალებები არ ქმნის საჭიროებას, რომ გამოვიყენოთ kernel level thread ები, თუ რაღაც სპეციფიური მიზეზი არ გაგვაჩია.

თუმცა წოტა თემას, რომ გადავუხვიოთ ასევე არსებობს ენები, რომლებიც Multithreading ს საერთოდ არ ასაპორტებენ. ან შეიძლება გააჩნდეთ Multithreading ის მოდელი, მაგრამ არა პარალელიზმის.

ასეთი ენები ძირითადად Event-loop ს იყენებენ, თუმცა ამის შესახებ მოგვიანებით, შემდეგ სტატიაში ვისაუბროთ.

მემგონი საკმაოდ მსუყე სტატია გამოვიდა, თუ ზევით აღწერილი თემები და საკითხები გაინტერესებთ, აქვე გაგიზიარებთ რესურსებს რომლებსაც ბოლო თვეების განმავლობაში ვიყენებ.

  • CMU ს კურსი Introduction to computer systems — ეს კურსი ჩემმა ძალიან ჭვკიანმა მეგობარმა მირჩია რამოდენიმე თვის წინ და თამამად შემიძლია ვთქვა, რომ ერთ-ერთი ყველაზე ძნელი და ამავდროულად საინტერესო გზა არის იმისთვის, რომ გაიცნო როგორ მუშაობს სამყარო შენს ქვემოთ, იმისთვის რომ შენ, შენი საქმე ყოველდღიურად უმტკივნეულოდ შეასრულო.
  • Dinosour book — ამ წიგნის რეკომენდაცია Quora ზე Robert Love ის გან ვნახე, რომელიც ასევე Linux kernel development წიგნის ავტორია, იყო core engineer ი კერნელში და შემდეგ Director of engineering ი Google ში.
  • Bible of Operating systems — კლასიკა, მხოლოდ 6$ ად.
  • Vegard wiki — ეს ტიპი მე და ჩემმა მეგობარმა აღმოვაჩინეთ, როდესაც kernel ის source ში ვდიგერობდით. საკუთარი ვიკიპედია აქვს თითქმის ყველა Computer science ის თემაზე, ნამდვილი საჩუქარია.

ჩემი აზრით აგნოსტიკური მიდგომა ჩვენს სფეროში, აორმაგებს და ბევრად უფრო პროდუქტიულს გვხვდის ჩვენს საკუთარ domain ში. მნიშვნელობა არ აქვს ეს იქნება Web, Mobile თუ სხვა. System ური მიდგომა და ცოდნა, მჯერა რომ ბევრად უფრო კარგ დეველოპერებად გვაყალიბებს.

მადლობა.


Get back to the basics — Process ები, CPU Virtualization, IPC და Thread ები A.K.A ნაკადები was originally published in ka_GE on Medium, where people are continuing the conversation by highlighting and responding to this story.

]]>