Seri Konkurensi Swift 09|Pembatalan masalah pembatalan semantik di Konkurensi Swift
Yang benar-benar sulit untuk dikumpulkan adalah apakah sinyal pembatalan dapat melewati Tugas, menjembatani batas lapisan dan efek samping, dan jangan biarkan hasil lama diumpankan kembali ke halaman
Setelah proyek mengubah panggilan balik menjadi async/await, situasi yang umum terjadi adalah terdapat ilusi bahwa masalah konkurensi telah teratasi.
Tanda tangan fungsi menjadi lebih bersih, rantai panggilan dapat dilihat di sepanjang await, dan peringatan di Xcode bahkan lebih sedikit. Namun masalah online yang paling mengganggu sering kali mulai muncul pada tahap ini: halaman telah ditinggalkan, namun permintaan belum berhenti; istilah pencarian telah berubah, dan hasil lama telah muncul kembali; pengguna membatalkan unggahan secara manual, namun tugas mendasar tetap berjalan.
Jenis masalah ini paling mudah dikaitkan dengan “antarmuka tertentu terlalu lambat” atau “utas utama disegarkan pada waktu yang salah”. Tetapi jika Anda benar-benar membongkar tautannya dan melihatnya, intinya biasanya sinyal pembatalan tidak ditransmisikan sepanjang pohon tugas, lapisan jembatan, dan batas efek samping sampai akhir.
Penilaian saya adalah: **Setelah migrasi Konkurensi Swift selesai, bug konkurensi yang paling umum adalah orang berpikir bahwa “tugas induk dibatalkan, dan tugas-tugas berikut akan berhenti secara alami.” Pada kenyataannya, selama ada lapisan Task yang tidak terkontrol, pembungkus yang menjembatani API lama, atau efek samping yang tidak memeriksa status pembatalan, semantik pembatalan akan rusak pada lapisan tersebut. Pada akhirnya, halaman tersebut kadang-kadang tampak gelisah, tetapi sebenarnya itu berarti statusnya telah bercabang. **
Masalah seperti ini biasanya terungkap dalam umpan balik negara
Ini adalah pertama kalinya saya menangani masalah ini secara sistematis. Di permukaan tampak seperti crash, namun kenyataannya lebih dekat ke halaman pencarian. Beberapa orang selalu melaporkan bahwa “hasilnya akan melonjak dengan sendirinya”.
Logika halamannya tidak rumit:
- Pengguna memasukkan kata kunci;
- ViewModel memulai pencarian;
- Membatalkan tugas sebelumnya ketika kata kunci baru tiba;
- Segarkan daftar setelah permintaan kembali.
Di permukaan, proses ini sepenuhnya konsisten dengan metode penulisan Swift Concurrency yang direkomendasikan. Masalahnya adalah fenomena yang sangat aneh terlihat pada rekaman layar online:
- Pengguna pertama kali mencari
swift; - Kemudian ubah menjadi
swift concurrency; - Hasil baru muncul pertama kali di antarmuka;
- Setelah setengah detik, hasil lama menimpa daftar lagi.
Hal ini tidak dapat dijelaskan hanya dengan “meminta tidak sesuai”. Karena searchTask?.cancel() jelas ada di kodenya, dan cancel juga bisa dilihat di log.
Masalah sebenarnya terletak pada: **Tugas tingkat atas dibatalkan, tetapi lapisan bawah tidak menganggap “pembatalan” sebagai perubahan status yang harus segera ditutup. **
Selama ada lapisan lain dalam sistem yang terus mengirimkan hasil lama, UI akan menerimanya sebagai hasil yang sah.
Banyak pembatalan yang gagal, rusak pada lapisan kode penghubung yang tampak paling tidak berbahaya.
Breakpoint yang paling umum adalah ketika menggabungkan API callback lama ke dalam fungsi async, ia hanya “menunggu hasilnya kembali” dan tidak melakukan “apa yang harus dilakukan ketika hasilnya tidak kembali”.
Misalnya, situasi yang umum adalah mengemas permintaan jaringan seperti ini:
func loadUser(id: String) async throws -> User {
try await withCheckedThrowingContinuation { continuation in
apiClient.loadUser(id: id) { result in
continuation.resume(with: result)
}
}
}
Sintaksnya bagus dan fungsinya berfungsi. Namun kode ini memiliki dua premis default yang fatal:
- Sekalipun tugas luar dibatalkan, permintaan yang mendasarinya akan berhenti dengan sendirinya;
- Bahkan jika lapisan bawah tidak berhenti, panggilan balik tidak akan mempengaruhi keadaan saat ini jika muncul kembali nanti.
Kedua premis ini seringkali tidak berlaku dalam proyek nyata.
Jika apiClient masih berada di bawah URLSessionDataTask, SDK pihak ketiga, atau lapisan penyimpanan panggilan baliknya sendiri, maka pembatalan lapisan luar Task tidak akan ditransfer secara otomatis. Pembungkus async di atas hanya mengubah metode pemanggilan menjadi await, tetapi tidak mengizinkan lapisan yang mendasarinya mendapatkan semantik pembatalan.
Apa yang sebenarnya perlu dilakukan oleh lapisan jembatan adalah “menerjemahkan pembatalan lapisan luar menjadi tindakan pembatalan yang dapat dieksekusi.” Sesuatu seperti ini:
func loadUser(id: String) async throws -> User {
var request: Cancellable?
return try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { continuation in
request = apiClient.loadUser(id: id) { result in
continuation.resume(with: result)
}
}
} onCancel: {
request?.cancel()
}
}
Kode ini baru saja mulai mendekati “pembatalan benar-benar dapat diteruskan”.
Namun menulisnya disini saja tidak cukup, karena hanya menyelesaikan “coba jangan terus berjalan”, tetapi tidak menyelesaikan “cara menutup hasil yang terlambat”. Jika cancel() dari SDK yang mendasarinya tidak memiliki pembatalan semantik yang kuat, namun hanya berhenti sebanyak mungkin, callback mungkin masih kembali dalam kondisi balapan. Tingkat atas harus terus melakukan pemeriksaan pembatalan sebelum menerima hasilnya.
Yang benar-benar mengacaukan halaman ini adalah hasil yang lama masih dianggap sebagai hasil yang valid.
Banyak tim yang merasa lega saat melihat Task.isCancelled, namun hanya bisa menjawab “apakah tugas saat ini sudah ditandai dibatalkan”, namun tidak bisa menjawab “haruskah hasil ini tetap ada di halaman saat ini?”
Dalam skenario seperti penelusuran, pengaitan, dan peralihan detail, yang benar-benar perlu dijaga adalah kepemilikan hasil.
Cara penulisan ViewModel berikut ini sangat umum:
final class SearchViewModel: ObservableObject {
@Published private(set) var items: [Item] = []
private var searchTask: Task<Void, Never>?
func search(keyword: String) {
searchTask?.cancel()
searchTask = Task {
do {
let items = try await repository.search(keyword: keyword)
self.items = items
} catch {
self.items = []
}
}
}
}
Masalahnya tampaknya hanya pada satu panggilan pembatalan, namun yang sebenarnya hilang adalah dua lapisan perlindungan:
- Setelah pengembalian berhasil, konfirmasikan bahwa tugas saat ini masih valid;
- Pembatalan tidak dapat dianggap sebagai kesalahan normal jika gagal.
Cara penulisan yang lebih stabil adalah seperti ini:
final class SearchViewModel: ObservableObject {
@MainActor @Published private(set) var items: [Item] = []
private var searchTask: Task<Void, Never>?
func search(keyword: String) {
searchTask?.cancel()
searchTask = Task { [weak self] in
guard let self else { return }
do {
let items = try await repository.search(keyword: keyword)
try Task.checkCancellation()
await MainActor.run {
self.items = items
}
} catch is CancellationError {
// 取消不是失败,不清空 UI,不弹错误
} catch {
await MainActor.run {
self.items = []
}
}
}
}
}
Yang terpenting di sini adalah sikap di baliknya: **Pembatalan adalah aliran kendali yang normal, bukan kecelakaan yang tidak normal. **
Banyak halaman yang bergetar karena kode mengubah “pengguna telah pergi” menjadi “permintaan gagal, jadi hapus UI”. Akibatnya, tugas baru belum dirender, dan cabang yang salah dari tugas lama akan mengembalikan halaman ke keadaan kosong terlebih dahulu, yang secara visual tampak seperti kedipan acak.
Masalah lain yang lebih tersembunyi adalah pohon tugas telah lama rusak, dan semua orang mengira mereka berada dalam konkurensi terstruktur.
Salah satu manfaat Konkurensi Swift adalah konkurensi terstruktur membuat hubungan siklus hidup antara tugas orang tua dan anak menjadi lebih jelas. Namun hal yang paling mudah hilang dalam proyek ini adalah Task {} yang diambil secara acak oleh semua orang hanya untuk “menyelamatkan masalah”.
Misalnya, ketika halaman daftar dimasukkan untuk mengambil detail, mengambil rekomendasi, dan menyorot sorotan, banyak kode yang akan dipecah menjadi ini:
func refresh() async {
Task {
async let detail = repository.loadDetail()
async let recommendation = repository.loadRecommendation()
let result = try await (detail, recommendation)
render(result)
}
}
Tampaknya async/menunggu, tetapi masalah paling kritis dengan kode ini adalah: refresh() itu sendiri dan lapisan Task {} di dalamnya tidak lagi memiliki hubungan induk-anak yang terstruktur.
Artinya:
- Panggilan lapisan atas
refresh()segera berakhir; - Bahkan jika halaman tersebut dihancurkan;
- Bahkan jika tugas luar dibatalkan;
Task yang baru dibuka di lantai ini masih bisa terus berjalan.
Inilah alasan mengapa banyak halaman masih membuat permintaan bahkan setelah halaman tersebut keluar. Ini adalah kode yang secara aktif melewati konkurensi terstruktur.
Jika skenario seperti ini hanya untuk mendapatkan hasil secara paralel, cukup menulis langsung dalam konteks async saat ini:
func refresh() async throws -> ScreenData {
async let detail = repository.loadDetail()
async let recommendation = repository.loadRecommendation()
return try await ScreenData(
detail: detail,
recommendation: recommendation
)
}
Dengan cara ini, semantik pembatalan akan dikumpulkan bersama dengan rantai panggilan. Siapapun yang memprakarsainya akan bertanggung jawab; siapa pun yang membatalkannya akan menghentikannya bersama-sama.
Jika batas efek samping tidak dicentang untuk pembatalan, keadaan kotor yang paling sulit dijelaskan akan muncul.
Jika permintaan tidak dihentikan, itu hanya membuang-buang sumber daya. Jika efek sampingnya tidak dihentikan maka statusnya akan tertulis kotor.
Saya kemudian secara khusus menyelidiki jenis masalah yang sulit untuk direproduksi: setelah pengguna berpindah akun dengan cepat, data dari akun sebelumnya terkadang muncul di cache. Akhirnya menyatu, dan semantik pembatalan berhenti sebelum “mendapatkan data” dan tidak melanjutkan ke langkah “menulis efek samping”.
Kode seperti ini berbahaya:
func refreshProfile() async throws {
let profile = try await repository.fetchProfile()
cache.save(profile)
analytics.trackProfileLoaded(profile.id)
state = .loaded(profile)
}
Jika tugas telah dibatalkan ketika fetchProfile() kembali, namun tidak ada pemeriksaan pembatalan, maka penulisan cache berikutnya, titik terkubur, dan pembaruan status akan terus terjadi.
Apa yang Anda lihat di UI saat ini mungkin hanya terpental sesekali, namun di dalam sistem, data kotor telah ditempatkan di disk, dan biaya pemecahan masalah tiba-tiba akan meningkat pesat.
Pendekatan yang lebih bijaksana biasanya melakukan pemeriksaan eksplisit sebelum batas efek samping:
func refreshProfile() async throws {
let profile = try await repository.fetchProfile()
try Task.checkCancellation()
cache.save(profile)
analytics.trackProfileLoaded(profile.id)
state = .loaded(profile)
}
Langkah ini mungkin tampak sedikit mekanis, namun memecahkan masalah yang sangat nyata: **Pembatalan tidak hanya membatalkan “menunggu”, tetapi juga membatalkan “kirim”. **
Apa yang benar-benar perlu dilindungi sering kali adalah beberapa tindakan yang akan mengubah dunia lama.
Kesalahpahaman paling umum dalam kasus kegagalan adalah menangani semua kesalahan secara seragam.
Alasan mengapa banyak migrasi serentak berakhir panjang adalah karena tim suka menulis penutupan kesalahan ke dalam templat terpadu:
do {
let data = try await service.load()
state = .loaded(data)
} catch {
state = .error(error)
}
Ini bukan masalah dalam skenario kegagalan biasa, namun setelah dimasukkan ke dalam skenario seperti peralihan halaman frekuensi tinggi, pencarian Lenovo, input anti-guncangan, dan pembatalan unggahan, CancellationError tidak sama dengan kegagalan bisnis sebenarnya.
Mencampur keduanya akan membawa setidaknya tiga konsekuensi:
- Pengguna secara aktif meninggalkan halaman, tetapi dicatat sebagai kegagalan;
- Tingkat kesalahan pada titik-titik tersembunyi terlalu tinggi, sehingga menyesatkan penilaian stabilitas;
- Karena kesalahan terpadu di UI, muncul tombol bersulang, status kosong, atau coba lagi yang seharusnya tidak muncul.
Selama pembatalan ditampilkan sebagai kegagalan satu kali dalam proyek, sekumpulan masukan yang aneh dan tampaknya tidak berhubungan akan muncul kemudian:
- Daftar ini berulang kali dihapus saat mencari;
- Terkadang kesalahan terjadi setelah penyegaran pull-down selesai;
- Saat halaman kembali ke level sebelumnya, status kegagalan pemuatan akan berkedip.
Fenomena ini sangat terpisah-pisah, namun akar masalahnya sama: ** pembatalan aliran kontrol disalahartikan sebagai pengecualian bisnis. **
Batasan yang berlaku: tidak semua fungsi async harus diisi dengan pemeriksaan pembatalan
Semantik pembatalan itu penting, tetapi tidak setiap lapisan harus menulis Task.checkCancellation() secara mekanis.
Ada tiga posisi yang lebih saya hargai sekarang:
- Menjembatani pintu masuk ke API lama: Ini bertanggung jawab untuk membatalkan penerjemahan lapisan luar ke kemampuan yang mendasarinya;
- Titik peralihan fase untuk tautan yang memakan waktu lama: Misalnya, setelah menyelesaikan jaringan, bersiap untuk memecahkan kode, dan bersiap untuk menulis cache;
- Efek samping sebelum pengiriman: Tempat mana pun yang mengubah status, cache, postingan, atau penulisan ke database patut diperiksa kembali.
Di sisi lain, jika suatu fungsi hanya perhitungan murni, tidak memiliki titik penangguhan, dan tidak memiliki efek samping, maka tidak ada gunanya secara khusus memasukkan pemeriksaan pembatalan. Karena solusi sebenarnya terhadap pembatalan adalah “Jangan terus menulis tentang dunia lama”.
Ringkasan
Ilusi termudah yang diciptakan oleh Swift Concurrency adalah bahwa kode telah dipindahkan dari callback ke await, dan sistem secara alami telah memasuki era konkurensi yang lebih andal.
Namun proyek nyata tidak akan secara otomatis mendapatkan semantik pembatalan hanya karena sintaksisnya baru.
Apakah tugas induk masih dapat mengontrol tugas anak, apakah lapisan jembatan dapat meneruskan pembatalan, dan apakah hasil lama akan diblokir sebelum efek samping dikirimkan. Jika salah satu dari tiga hal ini terlewatkan, apa yang Anda lihat di halaman tersebut akan menjadi sistem negara yang bercabang.
Oleh karena itu, yang benar-benar perlu dikaji dalam permasalahan seperti ini adalah di level mana pembatalan berhenti. Selama pertanyaan ini tidak dijawab dengan jelas, semakin baru tata bahasanya, semakin mudah bagi orang untuk salah mengira bahwa mereka telah menulis konkurensi dengan benar.
What to read next
Want more posts about iOS?
Posts in the same category are usually the best next step for reading more on this topic.
View same categoryWant to keep following #iOS?
Tags are useful for related tools, specific problems, and similar troubleshooting notes.
View same tagWant to explore another direction?
If you are not sure what to read next, return to the homepage and start from categories, topics, or latest updates.
Back home