首页 归档 关于 learn love 工具

pthread的各种同步机制

Mutex Lock 互斥锁

MUTual-EXclude Lock,互斥锁。 它是理解最容易,使用最广泛的一种同步机制。顾名思义,被这个锁保护的临界区就只允许一个线程进入,其它线程如果没有获得锁权限,那就只能在外面等着。它使用得非常广泛,以至于大多数人谈到锁就是mutex。mutex是互斥锁,pthread里面还有很多锁,mutex只是其中一种。

mutex锁不是万能灵药

基本上所有的问题都可以用互斥的方案去解决,大不了就是慢点儿,但不要不管什么情况都用互斥,都能采用这种方案不代表都适合采用这种方案。而且这里所说的慢不是说mutex的实现方案比较慢,而是互斥方案影响的面比较大,本来不需要通过互斥就能让线程进入临界区,但用了互斥方案之后,就使这样的线程不得不等待互斥锁的释放,所以就慢了。甚至有些场合用互斥就很蛋疼,比如多资源分配,线程步调通知等。 如果是读多写少的场合,就比较适合读写锁(reader/writter lock),如果临界区比较短,就适合空转锁(pin lock)...

预防死锁

如果要进入一段临界区需要多个mutex锁,那么就很容易导致死锁,单个mutex锁是不会引发死锁的。要解决这个问题也很简单,只要申请锁的时候按照固定顺序,或者及时释放不需要的mutex锁就可以。这就对我们的代码有一定的要求,尤其是全局mutex锁的时候,更需要遵守一个约定。

如果是全局mutex锁,我习惯将它们写在同一个头文件里。一个模块的文件再多,都必须要有两个umbrella header file。一个是整个模块的伞,外界使用你的模块的时候,只要include这个头文件即可。另一个用于给模块的所有子模块去include,然后这个头文件里面就放一些公用的宏啊,配置啊啥的,全局mutex放在这里就最合适了。这两个文件不能是同一个,否则容易出循环include的问题。如果有人写模块不喜欢写这样的头文件的,那现在就要改了。

然后我的mutex锁的命名规则就是:作用_mutex_序号,比如LinkListMutex_mutex_1,OperationQueue_mutex_2,后面的序号在每次有新锁的时候,就都加一个1。如果有哪个临界区进入的时候需要获得多个mutex锁的,我就按照序号的顺序去进行加锁操作(pthread_mutex_lock),这样就能够保证不会出现死锁了。

还有另一种方案也非常有效,就是用pthread_mutex_trylock函数来申请加锁,这个函数在mutex锁不可用时,不像pthread_mutex_lock那样会等待。pthread_mutex_trylock在申请加锁失败时立刻就会返回错误:EBUSY(锁尚未解除)或者EINVAL(锁变量不可用)。一旦在trylock的时候有错误返回,那就把前面已经拿到的锁全部释放,然后过一段时间再来一遍。 当然也可以使用pthread_mutex_timedlock这个函数来申请加锁,这个函数跟pthread_mutex_trylock类似,不同的是,你可以传入一个时间参数,在申请加锁失败之后会阻塞一段时间等解锁,超时之后才返回错误。

Reader-Writter Lock 读写锁

前面mutex锁有个缺点,就是只要锁住了,不管其他线程要干什么,都不允许进入临界区。设想这样一种情况:临界区foo变量在被bar1线程读着,加了个mutex锁,bar2线程如果也要读foo变量,因为被bar1加了个互斥锁,那就不能读了。但事实情况是,读取数据不影响数据内容本身,所以即便被1个线程读着,另外一个线程也应该允许他去读。除非另外一个线程是写操作,为了避免数据不一致的问题,写线程就需要等读线程都结束了再写。

因此诞生了Reader-Writter Lock,有的地方也叫Shared-Exclusive Lock,共享锁。

Reader-Writter Lock的特性是这样的,当一个线程加了读锁访问临界区,另外一个线程也想访问临界区读取数据的时候,也可以加一个读锁,这样另外一个线程就能够成功进入临界区进行读操作了。此时读锁线程有两个。当第三个线程需要进行写操作时,它需要加一个写锁,这个写锁只有在读锁的拥有者为0时才有效。也就是等前两个读线程都释放读锁之后,第三个线程就能进去写了。总结一下就是,读写锁里,读锁能允许多个线程同时去读,但是写锁在同一时刻只允许一个线程去写。

认真区分使用场合,记得避免写线程饥饿

由于读写锁的性质,在默认情况下是很容易出现写线程饥饿的。因为它必须要等到所有读锁都释放之后,才能成功申请写锁。不过不同系统的实现版本对写线程的优先级实现不同。Solaris下面就是写线程优先,其他系统默认读线程优先。

比如在写线程阻塞的时候,有很多读线程是可以一个接一个地在那儿插队的(在默认情况下,只要有读锁在,写锁就无法申请,然而读锁可以一直申请成功,就导致所谓的插队现象),那么写线程就不知道什么时候才能申请成功写锁了,然后它就饿死了。

总的来说,这样的锁建立之后一定要设置优先级,不然就容易出现写线程饥饿。而且读写锁适合读多写少的情况,如果读、写一样多,那这时候还是用mutex锁比较合理。

spin lock 空转锁

上面在给出mutex锁的实现代码的时候提到了这个spin lock,空转锁。它是互斥锁、读写锁的基础。在其它同步机制里condition variable、barrier等都有它的身影。我先说一下其他锁申请加锁的过程,你就知道什么是spin lock了。

互斥锁和读写锁在申请加锁的时候,会使得线程阻塞,阻塞的过程又分两个阶段,第一阶段是会先空转,可以理解成跑一个while循环,不断地去申请锁,在空转一定时间之后,线程会进入waiting状态(对的,跟进程一样,线程也分很多状态),此时线程就不占用CPU资源了,等锁可用的时候,这个线程会被唤醒。

为什么会有这两个阶段呢?主要还是出于效率因素。

  • 如果单纯在申请锁失败之后,立刻将线程状态挂起,会带来context切换的开销,但挂起之后就可以不占用CPU资源了,原属于这个线程的CPU时间就可以拿去做更加有意义的事情。假设锁在第一次申请失败之后就又可用了,那么短时间内进行context切换的开销就显得很没效率。
  • 如果单纯在申请锁失败之后,不断轮询申请加锁,那么可以在第一时间申请加锁成功,同时避免了context切换的开销,但是浪费了宝贵的CPU时间。假设锁在第一次申请失败之后,很久很久才能可用,那么CPU在这么长时间里都被这个线程拿来轮询了,也显得很没效率。

于是就出现了两种方案结合的情况:在第一次申请加锁失败的时候,先不着急切换context,空转一段时间。如果锁在短时间内又可用了,那么就避免了context切换的开销,CPU浪费的时间也不多。空转一段时间之后发现还是不能申请加锁成功,那么就有很大概率在将来的不短的一段时间里面加锁也不成功,那么就把线程挂起,把轮询用的CPU时间释放出来给别的地方用。

事实上,spin lock在实现的时候,有一个__pthread_spin_count限制,如果空转次数超过这个限制,线程依旧会挂起(__shed_yield)。

使用场合

了解了空转锁的特性,我们就发现这个锁其实非常适合临界区非常短的场合,或者实时性要求比较高的场合。
由于临界区短,线程需要等待的时间也短,即便轮询浪费CPU资源,也浪费不了多少,还省了context切换的开销。 由于实时性要求比较高,来不及等待context切换的时间,那就只能浪费CPU资源在那儿轮询了。

不过说实话,大部分情况你都不会直接用到空转锁,其他锁在申请不到加锁时也是会空转一定时间的,如果连这段时间都无法满足你的请求,那要么就是你扔的线程太多,或者你的临界区没你想象的那么短。

pthread_cleanup_push() & pthread_cleanup_pop()

线程是允许在退出的时候,调用一些回调方法的。如果你需要做类似的事情,那么就用以下这两种方法:

void pthread_cleanup_push(void (*callback)(void *), void *arg);
void pthread_cleanup_pop(int execute);

正如名字所暗示的,它背后有一个stack,你可以塞很多个callback函数进去,然后调用的时候按照先入后出的顺序调用这些callback。所以你在塞callback的时候,如果是关心调用顺序的,那就得注意这一点了。

但是!你塞进去的callback只有在以下情况下才会被调用:

  1. 线程通过pthread_exit()函数退出
  2. 线程被pthread_cancel()取消
  3. pthread_cleanup_pop(int execute)时,execute传了一个非0值

pthread_join()

在线程结束的时候,我们能通过上面的pthread_cleanup_push塞入的callback方法知道,也能通过pthread_join这个方法知道。一般情况下,如果是出于业务的需要要知道线程何时结束的,都会采用pthread_join这个方法。

它适用这样的场景:

你有两个线程,B线程在做某些事情之前,必须要等待A线程把事情做完,然后才能接着做下去。这时候就可以用join。

  int pthread_join(pthread_t thread, void **value_ptr);

在B线程里调用这个方法,第一个参数传A线程的thread_id, 第二个参数你可以扔一个指针进去。当A线程调用pthread_exit(void *value_ptr)来结束的时候,A的value_ptr就会到pthread_join的value_ptr去,你可以理解成A把它计算出来的结果放到exit函数里面去,然后其他join的线程就能拿到这个数据了。

在B线程join了A线程之后,B线程会阻塞住,直到A线程跑完。A线程跑完之后,自动被detach,后续再要join的线程就会报EINVAL。

Condition Variables 条件变量

pthread_join解决的是多个线程等待同一个线程的结束。条件变量能在合适的时候唤醒正在等待的线程。具体什么时候合适由你自己决定。它必须要跟互斥锁联合起来用。原因我会在注意事项里面讲。

场景:B线程和A线程之间有合作关系,当A线程完成某件事情之前,B线程会等待。当A线程完成某件事情之后,需要让B线程知道,然后B线程从等待状态中被唤醒,然后继续做自己要做的事情。

如果不用条件变量的话,也行。那就是搞个volatile变量,然后让其他线程不断轮询,一旦这个变量到了某个值,你就可以让线程继续了。如果有多个线程需要修改这个变量,那就再加个互斥锁或者读写锁。
但是!!!这做法太特么愚蠢了,还特别浪费CPU时间,所以还在用volatile变量标记线程状态的你们也真是够了!!!

大致的实现原理是:一个条件变量背后有一个池子,所有需要wait这个变量的线程都会进入这个池子。当有线程扔出这个条件变量的signal,系统就会把这个池子里面的线程挨个唤醒。

semaphore 信号量

semaphore事实上就是我们学《操作系统》的时候所说的PV操作。 你也可以把它理解成带有数量控制的互斥锁,当sem_init(&sem, 0, 1);时,他就是一个mutex锁了。

场景:比如有3台打印机,有5个线程要使用打印机,那么semaphore就会先记录好有3台,每成功被申请一次,就减1,减到0时,后面的申请就会被拒绝。

它也可以用mutex和条件变量来实现,但实际上还是用semaphore比较方便。

semaphore下的死锁

mutex下的死锁比较好处理,因为mutex只会锁一个资源(当semaphore的值为1时,就是个mutex锁),按照顺序来申请mutex锁就好了。但是到了semaphore这里,由于资源数量不止1个,死锁情况就显得比较复杂。

Barriers

Barrier可以理解成一个mile stone。当一个线程率先跑到mile stone的时候,就先等待。当其他线程都到位之后,再从等待状态唤醒,继续做后面的事情。

场景:超大数组排序的时候,可以采用多线程的方案来排序。比如开10个线程分别排这个超大数组的10个部分。必须要这10个线程都完成了各自的排序,你才能进行后续的归并操作。先完成的线程会挂起等待,直到所有线程都完成之后,才唤醒所有等待的线程。

前面有提到过条件变量和pthread_join,前者是在做完某件事情通知其他线程,后者是在线程结束之后让其他线程能够获得执行结果。如果有多个线程同时做一件事情,用上面这两者可以有次序地进行同步。另外,用semaphore也可以实现Barrier的功能。

但是我们已经有Barrier了好吗!你们能不要把代码搞那么复杂吗!

总结

这篇文章主要讲了pthread的各种同步机制相关的东西:mutex、reader-writter、spin、cleanup callbacks、join、condition variable、semaphore、barrier。其中cleanup callbacks不算是同步机制,但是我看到也有人拿这个作为同步机制的一部分写在程序中,这是不对的!所以我才写了一下这个。

原文

https://casatwy.com/pthreadde-ge-chong-tong-bu-ji-zhi.html