Back home

Seri Konkurensi Swift 06|Masalah umum dalam konkurensi Swift: kondisi balapan, permintaan berulang, dan kebingungan status

Masalah sebenarnya adalah masalah-masalah ini sering kali muncul sebagai gangguan sporadis dalam bisnis, bukan kerusakan yang nyata.

Hal yang paling membuat frustrasi tentang bug konkurensi adalah sering kali bug tersebut tidak terasa seperti bug.

Hal ini lebih sering muncul secara online sebagai pertanyaan ambigu berikut:

  • Pengguna berkata “terkadang berkedip”
  • Tes mengatakan “Kadang-kadang muncul data lama”
  • Produknya bilang “Saya baru potong filternya, kok loncat balik lagi?”
  • Tidak ada kerusakan yang jelas di log, tetapi status halaman salah.

Dengan kata lain, banyak masalah konkurensi lebih terlihat seperti “pengecualian bisnis sesekali” daripada “yang jelas-jelas rusak secara teknis”.

Jadi dalam artikel ini, saya tidak ingin hanya berbicara tentang definisi istilah, tetapi langsung fokus pada skenario halaman daftar yang lebih nyata dan menguraikan tiga jenis masalah yang paling umum:

  • Kompetisi
  • Ulangi permintaan
  • keadaan kebingungan

Dan bagaimana mereka berkembang dalam kode nyata.

1. Pertama-tama lihatlah halaman yang begitu nyata hingga sangat nyata.

Misalkan ada halaman daftar artikel yang mendukung operasi ini:

  • Pemuatan otomatis saat halaman masuk untuk pertama kali
  • Tarik ke bawah untuk menyegarkan
  • Beralih kategori
  • Masukkan pencarian kata kunci
  • Klik “Coba Lagi”

Banyak proyek yang ditulis seperti ini di awal:

@MainActor
final class ArticlesViewModel: ObservableObject {
  @Published var items: [Article] = []
  @Published var isLoading = false
  @Published var errorMessage: String?
  @Published var selectedCategory: String = "all"
  @Published var keyword: String = ""

  let repository: ArticlesRepository

  init(repository: ArticlesRepository) {
    self.repository = repository
  }

  func onAppear() {
    Task {
      await load()
    }
  }

  func refresh() {
    Task {
      await load()
    }
  }

  func retry() {
    Task {
      await load()
    }
  }

  func categoryChanged(to value: String) {
    selectedCategory = value
    Task {
      await load()
    }
  }

  func keywordChanged(to value: String) {
    keyword = value
    Task {
      await load()
    }
  }

  func load() async {
    isLoading = true
    errorMessage = nil

    do {
      items = try await repository.fetchArticles(
        category: selectedCategory,
        keyword: keyword
      )
    } catch {
      errorMessage = error.localizedDescription
    }

    isLoading = false
  }
}

Saat kode ini pertama kali ditulis, semua orang biasanya menganggapnya “cukup lancar”:

  • Ya async/await
  • Kodenya mudah
  • Setiap pintu masuk berfungsi

Namun selama halaman tersebut benar-benar digunakan, masalah konkurensi akan segera muncul.

2. Jenis masalah pertama: kondisi balapan adalah urutan default yang tidak ada.

Masih kode ini. Masalah intinya bukanlah bahwa ia membuka banyak Task, tetapi secara default hal-hal berikut terjadi sesuai urutan yang Anda inginkan:

  • Permintaan yang dikirim terlebih dahulu akan dikembalikan terlebih dahulu.
  • Ketika permintaan lama muncul kembali, kondisi pemfilteran saat ini tidak berubah.
  • Awal dan akhir pemuatan selalu bersesuaian satu-ke-satu

Namun sistem asinkron tidak menjamin perintah ini untuk tim.

Misalnya, pengguna beroperasi sebagai berikut:

  1. Masuk ke halaman dan minta A untuk menerbitkan
  2. Segera beralih ke kategori “iOS” dan minta B untuk mengirim
  3. Masukkan lagi kata kunci swift untuk meminta C mengeluarkannya

Saat ini, jika pesanan pengembalian adalah:

  1. C kembali dulu
  2. Kembalilah setelah A
  3. B kembali terakhir

Sesuai dengan kode saat ini, ketiga hasil tersebut akan diubah menjadi items. Dengan kata lain, apa yang ditampilkan di halaman terakhir bergantung pada siapa yang kembali terakhir, bukan siapa yang sesuai dengan niat pengguna saat ini.

Ini adalah kondisi balapan yang paling umum:

Kode diam-diam mengandalkan pesanan, tetapi pesanan tidak dibatasi sama sekali.

3. Jenis masalah kedua: Penyebab utama permintaan berulang biasanya adalah pintu masuk tidak ditutup.

Melihat ViewModel di atas, setidaknya ada lima entri yang akan memicu load():

  • onAppear
  • refresh
  • retry
  • categoryChanged
  • keywordChanged

Setiap pintu masuk memiliki Task sendiri. Hal ini tentu sah dari sudut pandang sintaksis, tetapi dari sudut pandang teknik artinya:

  • Tidak ada titik penjadwalan terpadu untuk tugas serupa
  • Tidak ada yang tahu apakah tugas serupa sudah berjalan
  • Ketika tugas baru muncul, tugas lama tidak memiliki nasib yang jelas

Maka “permintaan berulang” bukan lagi suatu kebetulan, melainkan produk alami dari struktur tersebut.

Jadi dalam manajemen konkurensi, saya jarang bertanya:

“Mengapa ada permintaan tambahan di sini?”

Saya lebih sering bertanya:

“Ada berapa pintu masuk untuk jenis tugas yang sama? Apakah ada hubungan substitusi di antara mereka?”

Jika Anda tidak dapat menjawab kedua pertanyaan ini, permintaan berulang hampir tidak bisa dihindari.

4. Tipe Permasalahan Ketiga : Status tidak teratur, seringkali karena hasil yang sudah kadaluwarsa masih layak untuk ditulis.

Situasi yang umum terjadi adalah selama permintaan berhasil dikembalikan, hasilnya harus diterima.

Hal ini biasanya baik pada sistem sinkron, namun sering kali salah pada sistem konkuren.

Karena masalah paling kritis dalam skenario bersamaan adalah:

**Apakah hasil ini masih dianggap sebagai hasil yang valid untuk halaman saat ini? **

Misalnya:

  • Halaman saat ini telah dialihkan ke keyword = "swift"
  • Hasilnya dari request lama keyword = ""

Hasilnya nyata, sukses, dan dalam format yang tepat, namun sudah habis masa berlakunya. Kalau masih boleh menulis UI, keadaannya salah.

Oleh karena itu, dalam sistem konkuren, “hasilnya benar” dan “hasilnya valid” adalah dua hal yang berbeda. Di permukaan, banyak masalah halaman yang tampaknya merupakan hasil yang salah, namun kenyataannya, ini lebih mendekati ketidakmampuan untuk menilai apakah masalah tersebut masih memenuhi syarat untuk diterapkan.

5. Jangan terburu-buru menggunakan alat yang rumit terlebih dahulu. Langkah pertama adalah menutup tugas serupa.

Yang paling dibutuhkan kode di atas adalah melakukan hal yang sangat sederhana terlebih dahulu:

**Berikan tugas serupa pintu masuk terpadu. **

Misalnya, muat dulu daftarnya seperti ini:

@MainActor
final class ArticlesViewModel: ObservableObject {
  @Published private(set) var state: ViewState = .idle
  @Published private(set) var items: [Article] = []
  @Published var selectedCategory: String = "all"
  @Published var keyword: String = ""

  private let repository: ArticlesRepository
  private var loadTask: Task<Void, Never>?

  init(repository: ArticlesRepository) {
    self.repository = repository
  }

  func reload() {
    let request = RequestContext(
      category: selectedCategory,
      keyword: keyword
    )

    loadTask?.cancel()
    loadTask = Task {
      await performLoad(request: request)
    }
  }

  private func performLoad(request: RequestContext) async {
    state = .loading

    do {
      let result = try await repository.fetchArticles(
        category: request.category,
        keyword: request.keyword
      )

      guard !Task.isCancelled else { return }
      guard request.category == selectedCategory,
         request.keyword == keyword else { return }

      items = result
      state = .loaded
    } catch is CancellationError {
      // 取消不更新页面
    } catch {
      guard !Task.isCancelled else { return }
      state = .failed(error.localizedDescription)
    }
  }
}

Kode ini melakukan beberapa hal yang sangat penting:

  • Hanya ada satu titik tunggu untuk tugas pemuatan serupa loadTask
  • Jika ada tugas baru, tugas lama akan dibatalkan terlebih dahulu
  • Bekukan “konteks saat ini” ke RequestContext saat mengirim permintaan
  • Setelah hasilnya dikembalikan, akan diverifikasi apakah masih sesuai dengan halaman saat ini

Perhatikan bahwa yang paling penting di sini adalah hubungan tugas mulai menjadi jelas.

6. “Membekukan konteks permintaan” sangat penting

Banyak artikel konkurensi berbicara tentang pembatalan tugas, tetapi tidak cukup menekankan pada “snapshot konteks”. Namun dalam bisnis halaman, ini sangat penting.

Misalnya, ketika meminta:

  • selectedCategory = "ios"
  • keyword = "swift"

Maka kedua nilai ini tidak boleh secara dinamis membaca nilai terbaru pada ViewModel saat ini setelah permintaan dihentikan. Jika tidak, Anda akan sering mendapatkan keadaan yang sangat aneh:

  • Saat mengirim permintaan, itu adalah sekumpulan parameter
  • Kumpulan parameter lain digunakan saat memverifikasi hasil

Jadi prinsip yang sangat praktis adalah:

Saat memulai tugas asinkron, bekukan konteks bisnis yang benar-benar bergantung pada tugas tersebut.

Dengan cara ini, akan ada dasar yang jelas untuk menilai “apakah hasil ini masih merupakan hasil saat ini” nantinya.

7. Banyak bug konkurensi berakhir dengan “terlalu banyak entri penulisan status”

Situasi yang umum terjadi adalah ketika menghadapi masalah konkurensi, Anda akan langsung memikirkan:

  • Apakah kamu ingin menguncinya?
  • Apakah kamu ingin menjadi Aktor?
  • Apakah Anda ingin mengganti topik?

Tentu saja hal ini terkadang penting, namun dalam skenario tingkat halaman, masalah yang lebih umum sebenarnya adalah:

  • Terlalu banyak tempat untuk menulis items
  • Terlalu banyak tempat yang dapat diubah isLoading
  • Terlalu banyak pintu masuk dapat mengirim permintaan secara langsung

Begitu entri penulisan negara tersebar, meskipun tidak ada persaingan data nyata, fenomena “kombinasi salah” akan terjadi.

Jadi ketika saya melakukan pemecahan masalah seperti ini, saya biasanya menanyakan pertanyaan berikut terlebih dahulu:

  • Kode mana yang mempunyai kewenangan untuk mengubah status ini
  • Tugas mana yang berhak mengakhiri pemuatan saat ini
  • Hasil mana yang berhak menimpa daftar saat ini

Jika masalah ini tidak diatasi, biasanya hanya masalah waktu saja sebelum bug berkembang.

8. Urutan evolusi yang mendekati proyek sebenarnya

Jika Anda benar-benar ingin menyelesaikan masalah seperti ini, saya sarankan untuk mengembangkannya dalam urutan ini daripada memperkenalkan terlalu banyak mekanisme di awal:

1. Tutup pintu masuk ke tugas serupa

Pertama, biarkan “pemuatan daftar” hanya memiliki satu pintu masuk terpadu, alih-alih mengirimkan permintaannya sendiri untuk setiap peristiwa UI.

2. Memperjelas hubungan penggantian tugas

Tugas mana yang harus dilakukan secara bersamaan dan tugas mana yang harus membatalkan tugas lama dan hanya mempertahankan tugas terakhir.

3. Bekukan konteks permintaan

Kumpulkan parameter bisnis utama yang diandalkan saat membuat permintaan menjadi objek yang jelas.

4. Tambahkan penilaian validitas pada hasilnya

Tidak semua hasil yang berhasil dikembalikan memenuhi syarat untuk mengubah halaman saat ini.

5. Terakhir, pertimbangkan isolasi negara bersama yang lebih kompleks

Misalnya, cache bersama lintas halaman, koordinasi sumber daya lintas modul, lalu lihat Aktor, koordinator terpadu, dan solusi lainnya.

Urutan ini lebih stabil karena menyelesaikan hubungan konkurensi bisnis terlebih dahulu, dibandingkan memperkenalkan kosakata teknis yang lebih kompleks terlebih dahulu.

9. Kesimpulan: Inti dari sebagian besar masalah konkurensi bisnis adalah “tidak ada pemodelan hubungan tugas”

Kondisi balapan, permintaan duplikat, dan kebingungan negara tampaknya merupakan tiga masalah, namun akar permasalahan sebenarnya sering kali sangat mirip:

  • Siapa tugasnya sama dengan siapa, tidak ada pemodelan
  • Tugas baru akan datang, apa yang harus dilakukan dengan tugas lama, tidak ada pemodelan
  • Apakah hasilnya masih valid? Tidak ada pemodelan.
  • Dimana saya bisa menulis status saya tanpa menutupnya?

Jadi untuk menyusun ulang artikel ini dengan lebih singkat, saya akan mengatakan:

Sebagian besar masalah konkurensi dalam bisnis tampaknya tidak kompeten dengan sintaks konkurensi, namun kenyataannya masalah tersebut hampir gagal dalam memodelkan hubungan tugas, validitas hasil, dan izin penulisan status dengan jelas.

Ketika ketiga hal ini mulai menjadi jelas, banyak “kebingungan yang tidak disengaja” akan hilang lebih mudah dari yang Anda kira.