ÇSTech
Published in

ÇSTech

Swift Concurrency ile Async-Await Kullanımı

Merhaba, bu yazımda son zamanlarda eklenen yeni özellikler ile gücüne güç katan Concurrency Framework’ün bazı kavramlarından ve örnekler üstünden kullanımlarından bahsedeceğim.

Genel olarak bahsedeceğimiz kavramlar;

  • Async-Await
  • Task
  • Continuation
  • Async Sequence
  • Actor / MainActor

Async-Await

Swift’te asenkron isteklerin geriye dönüş değerlerini kullanabilmek için genelde closure kullanıyoruz.

func getUserInfo() {
getName { name in
getSurname(name: name) { surname in
getAge(name: name, surname: surname) { age in
print("Name: \(name), Surname: \(surname), Age: \(age)")
}
}
}
}
func getName(_ completion: @escaping (String) -> Void) {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
completion("John")
}
}
func getSurname(name: String, _ completion: @escaping (String) -> Void) {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
completion("Lennon")
}
}
func getAge(name: String, surname: String,_ completion: @escaping (Int) -> Void) {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
completion(40)
}
}

Bu kullanım iç içe farklı response dönen yapılarda oldukça karmaşık olabiliyor. Ayrıca Result dönen closure’larda, switch case gibi yapılarla kontrol edip ayrı logicler yazmak sürdürülebilir bir kod olmaktan çıkıyor.

Yukarıdaki kod bloğunu async-await kullanarak refactor edelim.

func getUserInfo() {
Task {
do {
let name = try await getName()
let surname = try await getSurname(name: name)
let age = try await getAge(name: name, surname: surname)
print("Name: \(name), Surname: \(surname), Age: \(age)")
} catch {
print(error.localizedDescription)
}
}
}
func getName() async throws -> String {
try await Task.sleep(seconds: 2)
return “John"
}
func getSurname(name: String) async throws -> String {
try await Task.sleep(seconds: 2)
return “Lennon"
}
func getAge(name: String, surname: String) async throws -> Int {
try await Task.sleep(seconds: 2)
return 40
}

Gördüğünüz gibi oldukça kısa ve anlaşılabilir görünüyor. Kavramlardan genel olarak bahsedecek olursak,

async -> Fonksiyonun bekletilebileceğini belirtir. Fonksiyonu çağıran diğer yerlerde de beklenmesi gerekiyorsa o kısımlar da async tanımlanmalıdır.
await -> async fonksiyonun işi için akışı bekletir. Bu sırada fonksiyon async olduğu için diğer işler devam eder, bu süreçten etkilenmez.
async fonksiyon işini bitirdiğinde, await sonrasındaki kod çalışmaya devam eder.
Task -> Senkron ve asenkron işlemler için köprü görevi görür. Asenkron işleri senkron bir task içinde işlemek için Task closure’ı içinde kullanabiliriz.

Ayrıca async-await için testlerimizi yaparken Task.sleep’ten faydalanabiliriz.

Mevcutta bir çok SDK async yapısını destekliyor. Örnek kullanımlar için aşağıdaki SDK’ları inceleyebilirsiniz.

Fakat backward compatibility sorunlarından dolayı bazı projelerde bunları kullanamayabiliriz. Bu tarz durumlarda completionHandler kullanan bir fonksiyonu async-await yapısına uyarlayabiliriz. Bunun için withCheckedContinuation ve withCheckedThrowingContinuation kullanabiliriz.

Yukarıdaki completion closure’ı ile tanımladığımız getName fonksiyonunu aşağıdaki gibi async-await yapısına çevirebiliriz.

func getName(_ completion: @escaping (String) -> Void) {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
completion("John")
}
}
func getName() async -> String {
return await withCheckedContinuation({ continuation in
getName { name in
continuation.resume(returning: name)
}
})
}

Ayrıca async-await ile paralel istekler de atabiliriz. Bir örnek üstünden paralel istekleri de inceleyelim.

Bütün istekler döndüğünde print kodu çalışır

Async Sequence

Async-await yapısını Async Sequence sayesinde for-loop gibi yapılarda da kullanabiliriz. Ayrıca async sequence ile custom sequence yazabilir asenkron bi şekilde çalıştırabiliriz.

Daha iyi anlaşılması için örnek üstünden inceleyelim.

struct SampleSequence: AsyncSequence {
typealias Element = Int
private let numberList: [Int]

init(numberList:[Int]) {
self.numberList = numberList
}

func makeAsyncIterator() -> SampleIterator {
SampleIterator(numberList: numberList)
}
}
struct SampleIterator: AsyncIteratorProtocol {

private var index: Int = .zero

private let numberList: [Int]
init(numberList: [Int]) {
self.numberList = numberList
}

mutating func next() async throws -> Int? {
guard index < numberList.count else {
return nil
}

let number = numberList[index]
index += 1

return number
}
}
let numbers = [1,2,3,4,5,6,7,8,9]
Task {
for try await number in SampleSequence(numberList: numbers) {
print(number)
}

for try await number in SampleSequence(numberList: numbers).filter({ $0 % 2 == .zero }) {
print(number)
}
}

Örnekte custom bir AsyncSequence ve AsyncIterator ekledik ve bu sequence üstünde asenkron şekilde for-loop çalıştırabildik.

Async Sequence map, filter gibi bir çok kullanımı da destekliyor.

Actor

Bildiğiniz gibi diziler swift dilinde thread-safe değil. Bu tarz değişkenleri farklı yerlerden aynı anda değiştirmeye çalıştığımızda data race durumları ortaya çıkıyor. Bunları engellemek için thread kullanıp concurrent queue üstünde barrier gibi özellikler ile veya serial queue kullanarak thread-safe yapılarımızı kurmamız gerekiyor.

Actor ile bu tür yapıları daha az kod yazarak ve daha anlaşılır biçimde kurgulayabiliriz. Genel özelliklerinden bahsedecek olursak,

  • Class, enum ve struct yapıları gibi concrete tiptedir
  • Referans tipindedir
  • Inheritance desteklemezler
  • Fonksiyon ve değişkenler barındırabilir, generic yapıları kullanabilir ve çeşitli protokolleri conform edebilirler

Öncelikle data race durumlarından kaçınmak için mevcutta queue kullanarak yazdığımız kodları bir örnek üstünden inceleyelim.

struct ThreadSafeQueueSample {
private var numberList: [Int] = []
private let sampleQueue = DispatchQueue(label: "sample",
attributes: .concurrent)

var totalCount: Int {
var count: Int = .zero
sampleQueue.sync {
count = self.numberList.count
}
return count
}

mutating func add(_ number: Int) {
sampleQueue.sync(flags: .barrier) {
self.numberList.append(number)
}
}

mutating func pop() -> Int? {
var number: Int?
sampleQueue.sync(flags: .barrier) {
number = self.numberList.popLast()
}
return number
}

func getNumber(at index: Int) -> Int? {
guard index < numberList.count else {
return nil
}
var number: Int?
sampleQueue.sync {
number = self.numberList[index]
}
return number
}
}

Yukarıdaki örnekte concurrent bir queue kullanıp yazma işlemlerini barrier ile serial hale getirdik, okuma işlemlerini ise senkron bi şekilde ilerletip thread-safe bir yapı oluşturduk. Fakat syntax olarak karmaşık görünüyor ve logic arttıkça anlaşılabilirliği azalıyor.

Şimdi aynı örneği Actor kullanarak yazalım.

actor ThreadSafeSample {
private var numberList: [Int] = []

let someImmutableValue = "Test"

var totalCount: Int {
return numberList.count
}

func add(_ number: Int) {
numberList.append(number)
}

func pop() -> Int? {
return numberList.popLast()
}

func getNumber(at index: Int) -> Int? {
guard index < numberList.count else {
return nil
}
return numberList[index]
}
}

Actor kullanarak low-level data race sorunlarından kurtulduk ve çok daha az kod ile daha anlaşılır bir yapı kurduk.

Actor, içindeki method ve mutable verileri izole ederek senkron bi yapı oluşturur. Bu yüzden içindeki bir fonksiyonu veya mutable değeri çağırmak istediğimizde aşağıdaki gibi hata alırız. Fakat immutable değerleri direkt olarak kullanabiliriz.

Bu hatayı engellemek için await kullanarak bir task içinde çalıştırabiliriz.

Ayrıca izole etmesini istemediğimiz herhangi bir computed değeri direkt olarak kullanmak için property tanımlarken nonisolated eklememiz yeterlidir.

MainActor

Swift 5.5 ile hayatımıza giren bir diğer kavram olan MainActor, içinde tanımlanan işleri main thread üstünde yapacak şekilde kurgulanmış özel bir Actor tipidir.

Bildiğimiz gibi, UI üzerinde yapılan işlemler main thread üzerinde yapılmalı.
Asenkron çalışan ve dönüşünde label’ın text’ini güncelleyecek bir örnek yazalım.

Gördüğünüz gibi getString fonksiyonu dönüşünde label text güncellenirken main thread üstünde işlem gerçekleştirdik. Fakat bu yöntem ile hem karmaşıklık artıyor hem de hataya daha açık bir hale geliyor.

Şimdi aynı örneği async-await ile tekrar yazalım.

completionHandler ile kullanılan getString yapısını bozmamak için continuation’dan faydalandık.

Async-await ile herhangi bir queue işlemine gerek kalmadan UI güncellemesini gerçekleştirebildik.

Fakat güncellemenin main thread’de yapılacağına nasıl karar verdik?

UI elemanları birer MainActor olarak tanımlandığı için güncellemelerini main thread üstünde gerçekleştirir.

Ayrıca kendi MainActor sınıfımızı da tanımlayabiliriz. Bu sınıfın içinde herhangi bir fonksiyonu main dışında kullanmak istersek nonisolated tanımlamamız yeterlidir.

Daha fazla detay için aşağıdaki kaynakları inceleyebilirsiniz..

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store