Swift — Race condition ები, Lock ები და Thread safety

Tornike Gomareli
10 min readAug 9, 2022

--

პროგრამირების რაღაც ეტაპზე ყველანი ვეჯახებით 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 ის კომპილატორში და როგორ მუშაობს ის ფარდის უკან.

მადლობა

--

--

Tornike Gomareli

Specialising in iOS and System Programming. Always trying to learn how to think better. twitter / @tornikegomareli