iOS multithreaded development: several details that are easily overlooked

iOS multithreaded development: several details that are easily overlooked

Generally speaking, iOS developers can handle most multi-threaded development as long as they can use a few simple APIs such as GCD, @synchronized, NSLock, etc. However, does this really achieve multi-threaded safety, and does it really fully utilize the efficiency advantages of multi-threading? Take a look at the following details that are easily overlooked.

Readers-writers problem

First, let's look at the description of the reader-writer problem:

There are two sets of concurrent threads, readers and writers, sharing the same data. When two or more reader threads access the shared data at the same time, no side effects will occur. However, if a writer thread and other threads (reader threads or writer threads) access the shared data at the same time, data inconsistency errors may occur. Therefore, the following requirements are required:

  • Allow multiple readers to perform read operations on shared data simultaneously;
  • Only one writer is allowed to write to shared data;
  • Any writer does not allow other readers or writers to work until the write operation is completed;
  • Before a writer performs a write operation, it should make all existing readers and writers exit.

From the above description, we can know that the so-called "reader-writer problem" refers to the synchronization problem of ensuring that a writing thread must access a shared object exclusively with other threads. Concurrent read operations are allowed, but write operations must be mutually exclusive with other read and write operations.

Most client apps simply pull the latest data from the network, process the data, and display the data in a list. This process involves both writing the latest data to the local server and reading the local data from the upper-level business. Therefore, it involves a large number of multi-threaded read and write operations. Obviously, these basically fall into the category of reader-writer problems [1].

However, I noticed that when faced with multi-threaded reading and writing problems, most iOS developers would immediately think of locking, or simply avoid using multi-threading, but few would try to use the reader-writer problem to further improve efficiency.

The following is a sample code to implement a simple cache:

  1. //Implement a simple cache
  2. - (void)setCache:(id)cacheObject forKey:(NSString *) key {
  3. if ( key .length == 0) {
  4. return ;
  5. }
  6. [_cacheLock lock];
  7. self.cacheDic[ key ] = cacheObject;
  8. ...
  9. [_cacheLock unlock];
  10. }
  11.  
  12. - (id)cacheForKey:(NSString * key ) {
  13. if ( key .length == 0) {
  14. return nil;
  15. }
  16. [_cacheLock lock];
  17. id cacheObject = self.cacheDic[ key ];
  18. ...
  19. [_cacheLock unlock];
  20. return cacheObject;
  21. }

The above code uses a mutex lock to implement multi-threaded reading and writing, which ensures safe reading and writing of data. However, the efficiency is not perfect because in this case, although the write operation and other operations are mutually exclusive, the read operations are also mutually exclusive, which will waste CPU resources. How to improve it? It is not difficult to find that this is actually a typical reader-writer problem. Let's take a look at the pseudo code to solve the reader-writer problem:

  1. semaphore ReaderWriterMutex = 1; //Realize read-write mutual exclusion
  2. int Rcount = 0; //Number of readers
  3. semaphore CountMutex = 1; //Reader modifies count mutex
  4.  
  5. writer(){
  6. while( true ){
  7. P(ReaderWriterMutex);
  8. write;
  9. V(ReaderWriterMutex);
  10. }
  11.      
  12. }
  13.  
  14. reader(){
  15. while( true ){
  16. P(CountMutex);
  17. if(Rcount == 0) //When the first reader comes in, block the writer
  18. P(ReaderWriterMutex);
  19. ++Rcount;
  20. V(CountMutex);
  21.  
  22. read ;
  23.  
  24. P(CountMutex);
  25. --Rcount;  
  26. if(Rcount == 0)
  27. V(ReaderWriterMutex); //When the last reader leaves, release the writer
  28. V(CountMutex);
  29. }
  30. }

In iOS, the PV primitive in the above code can be replaced with the semaphore API in GCD, dispatch_semaphore_t to implement it, but it is necessary to maintain an additional readerCount and implement the semaphore for readerCount mutual exclusive access. Manual implementation is more troublesome and it is difficult to encapsulate it into a unified interface. Fortunately, you can find ready-made reader-writer locks in iOS development:

pthread_rwlock_t

This is an old C language function, which is used as follows:

  1. // Initialization of lock, pthread_rwlock_t is a value type and must be declared as var in   order   to refer it later. Make sure not   to copy it.
  2. var lock = pthread_rwlock_t()
  3. pthread_rwlock_init(&lock, nil)
  4.  
  5. // Protecting read   section :
  6. pthread_rwlock_rdlock(&lock)
  7. // Read shared resource
  8. pthread_rwlock_unlock(&lock)
  9.  
  10. // Protecting write section :
  11. pthread_rwlock_wrlock(&lock)
  12. // Write shared resource
  13. pthread_rwlock_unlock(&lock)
  14.  
  15. // Clean up
  16. pthread_rwlock_destroy(&lock)

The interface is concise but not user-friendly. It should be noted that pthread_rwlock_t is a value type. Using = to assign a value will directly copy it, which will waste memory if you are not careful. In addition, you need to remember to destroy it after use, which is prone to errors. Is there a more advanced and easier-to-use API?

GCD barrier

Dispatch_barrier_async / dispatch_barrier_sync is not specifically used to solve the reader-writer problem. Barrier is mainly used in the following scenarios: when executing a task A, all operations previously added to the queue need to be completed, and tasks added later need to wait for task A to be completed before they can be executed, thereby isolating task A. The specific process is shown in the following figure:

If the concurrent tasks before and after the barrier task are replaced by read operations, and the barrier task itself is replaced by a write operation, dispatch_barrier_async / dispatch_barrier_sync can be used as a reader-writer lock. The cache code implemented using a normal lock at the beginning of the article is rewritten using dispatch_barrier_async for comparison:

  1. //Implement a simple cache (using ordinary locks)
  2. - (void)setCache:(id)cacheObject forKey:(NSString *) key {
  3. if ( key .length == 0) {
  4. return ;
  5. }
  6. [_cacheLock lock];
  7. self.cacheDic[ key ] = cacheObject;
  8. ...
  9. [_cacheLock unlock];
  10. }
  11.  
  12. - (id)cacheForKey:(NSString * key ) {
  13. if ( key .length == 0) {
  14. return nil;
  15. }
  16. [_cacheLock lock];
  17. id cacheObject = self.cacheDic[ key ];
  18. ...
  19. [_cacheLock unlock];
  20. return cacheObject;
  21. }

  1. //Implement a simple cache (using reader-writer locks)
  2. static dispatch_queue_t queue = dispatch_queue_create( "com.gfzq.testQueue" , ​​DISPATCH_QUEUE_CONCURRENT);
  3.  
  4. - (void)setCache:(id)cacheObject forKey:(NSString *) key {
  5. if ( key .length == 0) {
  6. return ;
  7. }
  8. dispatch_barrier_async(queue, ^{
  9. self.cacheDic[ key ] = cacheObject;
  10. ...
  11. });
  12. }
  13.  
  14. - (id)cacheForKey:(NSString * key ) {
  15. if ( key .length == 0) {
  16. return nil;
  17. }
  18. __block id cacheObject = nil;
  19. dispatch_sync(queue, ^{
  20. cacheObject = self.cacheDic[ key ];
  21. ...
  22. });
  23. return cacheObject;
  24. }

The cache implemented in this way can perform read operations concurrently while effectively isolating write operations, taking into account both security and efficiency.

For properties that are declared as atomic and have their getters or setters manually implemented, barriers can also be used to improve them:

  1. @property (atomic, copy) NSString *someString;
  2.  
  3. - (NSString *)someString {
  4. __block NSString *tempString;
  5. dispatch_sync(_syncQueue, ^{
  6. tempString = _someString;
  7. });
  8. return tempString;
  9. }
  10.  
  11. - (void)setSomeString :(NSString *)someString {
  12. dispatch_barrier_async(_syncQueue, ^{
  13. _someString = someString
  14. ...
  15. }
  16. }

While achieving atomicity, getters can also be executed concurrently, which is more efficient than directly putting setters and getters into serial queues or adding ordinary locks.

How much efficiency can readers and writers locks improve?

Using reader-writer locks is definitely faster than locking all reads and writes and using serial queues, but how much faster can it be? Dmytro Anokhin conducted an experimental comparison in [3] and measured the average time required to acquire locks when using NSLock, GCD barrier, and pthread_rwlock. The number of experimental samples ranged from 100 to 1000, excluding 10% of *** and ***. The results are shown in the following chart:


3 writers / 10 readers


1 writer / 10 readers


5 writers / 5 readers


10 writers / 1 reader

Analysis shows that:

  1. Using reader-writer locks (GCD barrier, pthread_rwlock) significantly improves efficiency compared to using ordinary locks (NSLock);
  2. The more readers there are and the fewer writers there are, the more efficiency advantage of using reader-writer locks becomes;
  3. There is little difference in efficiency between using GCD barrier and using pthread_rwlock.

Since pthread_rwlock is not easy to use and prone to errors, and the performance of GCD barrier and pthread_rwlock is comparable, it is recommended to use GCD barrier to solve the reader-writer problem encountered in iOS development. In addition, there is a potential advantage of using GCD: GCD is queue-oriented rather than thread-oriented. Tasks dispatched to a queue may be executed on any thread. This is transparent to developers. The benefits of such a design are obvious. GCD can select the thread with the lowest overhead from the thread pool it manages to execute tasks according to actual conditions, thereby minimizing the number of context switches.

When to use readers-writer locks

It should be noted that not all multi-threaded read and write scenarios are necessarily reader-writer problems, so be careful to distinguish when using them. For example, the following YYCache code:

  1. //Read cache
  2. - (id)objectForKey:(id) key {
  3. if (! key ) return nil;
  4. pthread_mutex_lock(&_lock);
  5. _YYLinkedMapNode *node = CFDictionaryGetValue(_lru->_dic, (__bridge const void *)( key ));
  6. if (node) {
  7. node->_time = CACurrentMediaTime();
  8. [_lru bringNodeToHead:node];
  9. }
  10. pthread_mutex_unlock(&_lock);
  11. return node ? node->_value : nil;
  12. }

  1. //Write cache
  2. - (void)setObject:(id)object forKey:(id) key withCost:(NSUInteger)cost {
  3. if (! key ) return ;
  4. if (!object) {
  5. [self removeObjectForKey: key ];
  6. return ;
  7. }
  8. pthread_mutex_lock(&_lock);
  9. _YYLinkedMapNode *node = CFDictionaryGetValue(_lru->_dic, (__bridge const void *)( key ));
  10. NSTimeInterval now = CACurrentMediaTime();
  11. if (node) {
  12. _lru->_totalCost -= node->_cost;
  13. _lru->_totalCost += cost;
  14. node->_cost = cost;
  15. node->_time = now;
  16. node->_value = object;
  17. [_lru bringNodeToHead:node];
  18. } else {
  19. node = [_YYLinkedMapNode new];
  20. node->_cost = cost;
  21. node->_time = now;
  22. node->_key = key ;
  23. node->_value = object;
  24. [_lru insertNodeAtHead:node];
  25. }
  26. if (_lru->_totalCost > _costLimit) {
  27. dispatch_async(_queue, ^{
  28. [self trimToCost:_costLimit];
  29. });
  30. }
  31. if (_lru->_totalCount > _countLimit) {
  32. _YYLinkedMapNode *node = [_lru removeTailNode];
  33. if (_lru->_releaseAsynchronously) {
  34. dispatch_queue_t queue = _lru->_releaseOnMainThread ? dispatch_get_main_queue() : YYMemoryCacheGetReleaseQueue();
  35. dispatch_async(queue, ^{
  36. [node class]; //hold and release in queue
  37. });
  38. } else if (_lru->_releaseOnMainThread && !pthread_main_np()) {
  39. dispatch_async(dispatch_get_main_queue(), ^{
  40. [node class]; //hold and release in queue
  41. });
  42. }
  43. }
  44. pthread_mutex_unlock(&_lock);
  45. }

The cache here uses the LRU elimination strategy. Each time the cache is read, the cache is placed at the front of the data structure, thereby delaying the elimination of the most recently used cache. Because a write operation also occurs at the same time as a read operation, the pthread_mutex mutex is used directly here instead of the reader-writer lock.

To sum up, if the multi-threaded reading and writing scenarios you encounter meet the following requirements:

  1. There is a pure read operation (that is, the read task does not include a write operation at the same time);
  2. There are more readers and fewer writers.

You should consider using reader-writer locks to further improve concurrency.

Notice:

(1) The reader-writer problem includes two categories: "reader priority" and "writer priority". The former means that as long as the reader thread sees that other reader threads are accessing the file, it can continue to read the file, and the writer thread must wait until all reader threads stop accessing the file before writing, even if the writer thread may submit an application earlier than some reader threads. The writer priority means that as long as the writer thread submits an application, the subsequent reader thread must wait for the writer thread to complete. GCD's barrier is a writer priority implementation. For details, please refer to document [2].

(2) There is no need to use GCD barriers on serial queues. Instead, use the concurrent queue created with dispatch_queue_create. Since dispatch_get_global_queue is a global shared queue, using barriers cannot isolate the current task and will automatically downgrade to dispatch_sync / dispatch_async. [5]

Granularity of Locks

First, let’s look at two pieces of code:

Code Snippet 1

  1. @property (atomic, copy) NSString *atomicStr;
  2.  
  3. //thread A
  4. atomicSr = @ "am on thread A" ;
  5. NSLog(@ "%@" , atomicStr);
  6.  
  7. //thread B
  8. atomicSr = @ "am on thread B" ;
  9. NSLog(@ "%@" , atomicStr);

Code Snippet 2

  1. - (void)synchronizedAMethod {
  2. @synchronized (self) {
  3. ...
  4. }
  5. }
  6.  
  7. - (void)synchronizedBMethod {
  8. @synchronized (self) {
  9. ...
  10. }
  11. }
  12.  
  13. - (void)synchronizedCMethod {
  14. @synchronized (self) {
  15. ...
  16. }
  17. }

Too small particle size

When executing code segment 1, the string printed on thread A may be "am on thread B". The reason is that although atomicStr is an atomic operation, after atomicStr is taken out and before NSLog is executed, atomicStr may still be modified by thread B. Therefore, the properties declared with atomic can only guarantee that the get and set of the property are complete, but cannot guarantee that the operations on the property after get and set are multi-thread safe. This is why the properties declared with atomic cannot necessarily guarantee multi-thread safety.

Similarly, it is not just the properties declared by atomic. If the granularity of the locks added by yourself during development is too small, thread safety cannot be guaranteed. Code segment 1 actually has the same effect as the following code:

  1. @property (nonatomic, strong) NSLock *lock;
  2. @property (nonatomic, copy) NSString *atomicStr;
  3.  
  4. //thread A
  5. [_lock lock];
  6. atomicSr = @ "am on thread A" ;
  7. [_lock unlock];
  8. NSLog(@ "%@" , atomicStr);
  9.  
  10. //thread B
  11. [_lock lock];
  12. atomicSr = @ "am on thread B" ;
  13. [_lock unlock];
  14. NSLog(@ "%@" , atomicStr);

If we want the program to print the set value after setting atomicStr as we originally intended, we need to increase the scope of the lock and include NSLog in the critical section:

  1. //thread A
  2. [_lock lock];
  3. atomicSr = @ "am on thread A" ;
  4. NSLog(@ "%@" , atomicStr);
  5. [_lock unlock];
  6.  
  7. //thread B
  8. [_lock lock];
  9. atomicSr = @ "am on thread B" ;
  10. NSLog(@ "%@" , atomicStr);
  11. [_lock unlock];

The sample code is very simple and it is easy to see the problem, but when you encounter more complex code blocks in actual development, you may fall into a trap if you are not careful. Therefore, when designing multi-threaded code, you should pay special attention to the logical relationship between the codes. If the subsequent code depends on the locked code, then these subsequent codes should also be added to the lock.

Too large granularity

The @synchronized keyword will automatically create a lock associated with the passed object, automatically lock it at the beginning of the code block, and automatically unlock it at the end of the code block. The syntax is simple and clear, and it is very easy to use, but this also causes some developers to rely too much on the @synchronized keyword and abuse @synchronized(self). As shown in the above code segment 2, in an entire class file, all locked places use @synchronized(self), which may cause unrelated threads to wait for each other when executing, and tasks that could have been executed concurrently have to be executed serially. In addition, using @synchronized(self) may also cause deadlock:

  1. //class A
  2.  
  3. @synchronized (self) {
  4. [_sharedLock lock];
  5. NSLog(@ "code in class A" );
  6. [_sharedLock unlock];
  7. }
  8.  
  9. //class B
  10. [_sharedLock lock];
  11. @synchronized (objectA) {
  12. NSLog(@ "code in class B" );
  13. }
  14. [_sharedLock unlock];

The reason is that self is likely to be accessed by external objects and used as a key to generate a lock, similar to @synchronized (objectA) in the above code. Deadlock is likely to occur when two public locks are used alternately. Therefore, the correct approach is to pass in an NSObject object maintained internally by the class, and this object is not visible to the outside world [2].

Therefore, different locks should be set for unrelated multi-threaded codes, and each lock only controls one critical section. In addition, there is another common mistake that will lead to a decrease in concurrency efficiency:

  1. //thread A
  2. [_lock lock];
  3. atomicSr = @ "am on thread A" ;
  4. NSLog(@ "%@" , atomicStr);
  5. //do some other tasks which are none of business with atomicStr;
  6. for ( int i = 0; i < 100000; i ++) {
  7. sleep(5);
  8. }
  9. [_lock unlock];
  10.  
  11. //thread B
  12. [_lock lock];
  13. atomicSr = @ "am on thread B" ;
  14. NSLog(@ "%@" , atomicStr);
  15. //do some other tasks which are none of business with atomicStr;
  16. for ( int i = 0; i < 100000; i ++) {
  17. sleep(5);
  18. }
  19. [_lock unlock];

That is, the critical section contains tasks that are unrelated to the current locked object. In practical applications, we need to pay special attention to each function in the critical section, because its internal implementation may call time-consuming and irrelevant tasks.

Recursive lock

Compared with the above-mentioned @synchronized(self), the deadlock caused by the following situation is more common:

  1. @property (nonatomic,strong) NSLock *lock;
  2.  
  3. _lock = [[NSLock alloc] init];
  4.  
  5. - (void)synchronizedAMethod {
  6. [_lock lock];
  7. //do some tasks
  8. [self synchronizedBMethod];
  9. [_lock unlock];
  10. }
  11.  
  12. - (void)synchronizedBMethod {
  13. [_lock lock];
  14. //do some tasks
  15. [_lock unlock];
  16. }

After method A has acquired the lock, calling method B will trigger a deadlock. Method B can only continue to execute after waiting for method A to complete and release the lock. The prerequisite for method A to complete is to complete method B. In actual development, the situation where deadlock may occur is often hidden in the layers of method calls. Therefore, it is recommended to use recursive locks when it is uncertain whether a deadlock will occur. A more conservative approach is to use recursive locks at all times, because it is difficult to guarantee that future code will not lock the same thread multiple times.

Recursive locks allow the same thread to repeatedly lock the lock it owns without releasing it, and this is done internally using a counter. In addition to NSRecursiveLock, you can also use pthread_mutex_lock, which has better performance, by setting the initialization parameter to PTHREAD_MUTEX_RECURSIVE:

  1. pthread_mutexattr_t attr;
  2. pthread_mutexattr_init (&attr);
  3. pthread_mutexattr_settype (&attr, PTHREAD_MUTEX_RECURSIVE);
  4. pthread_mutex_init (&_lock, &attr);
  5. pthread_mutexattr_destroy (&attr);

It is worth noting that @synchronized also uses recursive locks internally:

  1. // Begin synchronizing on   'obj' .
  2. // Allocates recursive mutex associated with   'obj' if needed.
  3. // Returns OBJC_SYNC_SUCCESS once lock is acquired.
  4. int objc_sync_enter(id obj)
  5. {
  6. int result = OBJC_SYNC_SUCCESS;
  7.  
  8. if (obj) {
  9. SyncData* data = id2data(obj, ACQUIRE);
  10. assert(data);
  11. data->mutex.lock();
  12. } else {
  13. // @synchronized(nil) does nothing
  14. if (DebugNilSync) {
  15. _objc_inform( "NIL SYNC DEBUG: @synchronized(nil); set a breakpoint on objc_sync_nil to debug" );
  16. }
  17. objc_sync_nil();
  18. }
  19.  
  20. return result;
  21. }

Summarize

If you want to write efficient and safe multi-threaded code, it is not enough to be familiar with the GCD, @synchronized, and NSLock APIs. You also need to understand more about the knowledge behind the APIs. It is necessary to have a deep understanding of the concept of critical sections and clarify the timing relationship between tasks.

<<:  Did you know? The mobile phone is 46 years old

>>:  iOS 13's new feature "Apple Login" is mandatory and sparks controversy

Recommend

Can the purified water sold in supermarkets really be used for experiments?

Author: Ye Shi Popular Science Author Reviewer: L...

Douyin Ecosystem Full Service User Manual

In this article, the author will describe Douyin’...

iPhone 6s sold less than OPPO last year. How can Apple save itself in China?

Since 2012, the iPhone has been the sales champio...

The Nine Swords of User Growth Strategy

Product " user growth " is the most tro...

How to create highly sticky products? Teach you 3 models

What is CLV? You must understand this word when m...

A guide to high-conversion landing page form design!

The landing page is the first point of contact fo...

How does QR code marketing increase the scanning rate?

In the future, data will serve as a factor of pro...

Are you stressed? The puppy passing by also cares | Nature Trumpet

Shark struck by boat Recently, scientists recorde...