کار با متغیرها در iOS از چند رشته (Multi-thread) - بخش ۲

خب توی بخش اول از نظر تئوری، مشکل کار با متغیرهای mutable از چند رشته را دیدیم و فهمیدیم چه جوری پیداشون کنیم. حالا که مشکل را میدونیم باید ببینیم چه جوری میشه حلش کرد.

توی ابجکتیو-سی کار تقریبا راحته. کافیه پراپرتی از نوع اتمیک تعریف شه. یا متغیر @synchronize باشه. اما توی سوییفت کار یه کم سخت‌تره چون اینها را نداریم.

خب بیاید فکر کنیم ببینیم چه راهی میتونیم پیاده کنیم. گفتیم علت این موضوع اینه که ار چند رشته همزمان میخوایم به یه متغیر یا منظقه‌ای از حافظه دسترسی داشته باشیم. خب میایم صورت مسئله را دستکاری می‌کنیم. یعنی فقط از یه رشته دسترسی داشته باشیم. مثلا متغیر را اینجوری تعریف کنیم:

class Object {
    private let _array_q = DispatchQueue(label: "Object.property.atomic")
    private var _array: [Int] = []
    
    public var array: [Int] {
        get {
            return _array_q.sync { return _array }
        }
        set {
            _array_q.async { self._array = newValue }
        }
    }
}

خب یه DispatchQueue ایجاد کردیم. توجه کنید DispatchQueue به طور پیشفرض سریال هست و تا یه بلوک کد اجراش کامل نشه بلوک بعدی را اجرا نمی‌کنه. یه متغیر stored هم داریم که مقدار را توی حافظه نگه می‌داره اما private تعریف کردیم که کسی بهش دسترسی نداشته باشه. تغییر این متغیر صرفا از طریق متغیر محاسباتی array امکان پذیر هست اما اینجا صرفا از طریق کیویی که تعریف کردیم دسترسی داریم.

توجه کنید نه فقط برای set که حتی برای get هم داریم از این کیو استفاده می‌کنیم. ممکنه خیال کنید اگر فقط set از طریق کیو باشه کافیه ولی اینجوری نیست و همه‌ی دسترسی‌ها صرفا باید از طریق یک کیوی خاص باشه.

خب مشکل را حل کردیم. (میتونید با TSan تست کنید!) اما چند تا مشکل جدید خلق کردیم!

۱- شاید برای چند تا متغیر محدود مناسب باشه ولی وقتی تعداد زیاد بشه، تعداد زیادی ترد بی‌مصرف و منتظر داریم که خب به معنی مصرف منابع کرنل هست.

۲- ساخت ترد کار پرهزینه‌ای هست. در کل این روش روش کندی هست. نسبت به روش دیگه‌ای که معرفی می‌کنیم ۱۵ برابر کندتر هست.

من وقتی این موضوع را فهمیدم شروع کردم به فکر و سرچ بیشتر. سمافور راه خوبی به نظر می‌رسه. سریع هم هست! اما یه مشکل عمده داره. به راحتی باعث deadlock میشه و اگر یکی از تردهای درگیر این deadlock ترد اصلی باشه، برنامه هنگ می‌کنه. خب پس باید بیشتر بگردیم.

با سرچ بیشتر رسیدم به pthread_mutex_t که جزوی از کتابخانه‌ی استاندارد C هست. مخصوص این کار هست. سریع هست. کار باهاش تقریبا ساده ست ولی مشکلش اینه که مدیریتش دستی هست. یعنی هروقت بهش نیازی ندارید خودتون باید destroy کنید. تصمیم گرفتم یه کلاس سوییفت بنویسم که این جزییات را برام انجام بده ولی خب متوجه شدم اپل قبلا این کار را کرده!

کمی داکیومنت‌های فاندیشن را شخم بزنیم به کلاس NSLock می‌رسیم. این کلاس یه wrapper برای mutex هست و از همون استفاده میکنه. ولی کار باهاش سوییفتی تره. حالا اگر بخوایم کد بالا را بازنویسی کنیم اینجوری میشه.

class Object {
     private let _array_lock = NSLock()
     private var _array: [Int] = []
     
     public var array: [Int] {
          get {
               _array_lock.lock()
               defer { _array_lock.unlock() }
               return _array
         }
         set {
               _array_lock.lock()
               defer { _array_lock.unlock() }
               return _array
          }
     }
}

خب مشکل حل شد. توجه کنید عبارت توی defer بعد از return اجرا میشه. پس اول فقل می‌کنیم. بعد متغیر را دستکاری می‌کنیم یا مقدارش را میخونیم. و بعد قفل را باز می‌کنیم.

اگر بخوایم می‌تونیم بین لاک و آنلاک هر عبارتی اجرا کنیم. مثلا append کنیم. اینجوری سریعتر از خوندن و نوشتن کل آرایه هست. بنابراین شاید لازم باشه متدی شبیه این هم داشته باشیم. تا امکان این را بده:

withThreadSafeArray<R>(_ handler: (_ array: [Int]) throws -> R) rethrows -> R {
    _array_lock.lock()
    defer { _array_lock.unlock() }
    return try handler(self._array)
}

استفاده‌ش هم ساده ست:

let removedElement: Int = withThreadSafeArray { (array) in
    array.append(1)
    return array.remove(at: 0)
}

اجرای lock توی یه ترد باعث میشه اگر یه ترد دیگه هم بخواد lock کنه، سیستم ترد دوم را منتظر نگه می‌داره تا unlock ترد اول اجرا بشه. از سمافور هم باهوشتره و اگر دو تا ترد لاک کنن و منتظر نتیجه‌ی همدیگه باشن،‌خودش میفهمه و به یکی از تردها اجازه‌ی اجرا میده تا مشکل ددلاک بوجود نیاد.

توجه کنید اگر بدون unlock، مجددا توی همون ترد lock اجرا بشه، باعث ددلاک می‌شه. ولی خب برخلاف سمافور این موضوع دست خودمون هست و نشون دهنده‌ی اینه که کد ایراد داره و باید درست شه.

منتهی اگر واقعا منطق کد جوری هست که نیاز به لاک‌های تو در تو توی یه ترد داریم، از NSRecursiveLock استفاده می‌کنیم. در این حالت ترد دوم اینقدر منتظر می‌مونه تا آخرین unlock ترد اول اجرا بشه. تا جای ممکن نباید از ریکرسیو لاک استفاده کرد چون باعث پوشش اشتباهات ما میشه و خودش رو نشون نمی‌ده.

ممکنه این لاک ریکرسیو مشهود نباشه. مثلا توی مثال بالا توی withThreadSafeArray کافیه بخوایم متغیر محاسباتی array رو بگیریم یا ست کنیم!

جمع بندی

برخلاف اسمش، پیاده‌سازی atomicity برای متغیر کار سختی نیست. فقط باید از موضوع درک داشته باشیم و بدونیم کجا و چه جوری ازش استفاده کنیم. دقت کنید برای کلاس‌هایی که صراحتا ترد سیف هستن، مثل NSCache، نیازی به این کار نیست و خود اپل برای شما این کار را انجام داده.

اگر خواستید، می‌تونید یه کلاس جنریک (نه استراکت!) بنویسید که متغیری به صورت اتمیک در اختیارتون بذاره تا لازم نباشه این کد boilerplate رو برای هر متغیر تکرار کنید.

راه‌های دیگه‌ای هم هست. مثل استفاده از objc_sync_enter/objc_sync_exit اگر آبجکت ما مشتق از NSObject باشه. یا استفاده از اسپین لاک (spinlock) که برای پرفورمنس بالاتر روی سی‌پی‌یوهای چندهسته‌ای مناسبتره. ولی اگر مطمئن نیستید از همین NSLock استفاده کنید.

اپل انواع کلاس‌های لاک دیگه برای شرایط شما گذاشته که می‌تونید داکیومنت اپل رو مطالعه کنید.

موفق باشید