Linux环境编程--线程安全

由于线程共享一份进程空间,所以当一个线程可以修改变量的时候,其他线程也可以读取或者修改,我们需要对这些线程进行同步,确保他们在访问变量的存储内容时不会访问到无效的值,假设在堆上有一份数据,线程A与B同时对他进行修改,那么就会造成竞态条件,因为这两个修改过程都不是原子操作, 都可以被打断,那么我们如何解决这个问题呢,这就引出了线程同步的概念。
线程通过同步与互斥机制来保证线程安全。我们这里介绍几种常用的同步互斥机制。

互斥量

对于线程操作临界资源从而引发的安全问题,我们可以总结为下面三点:

  1. 代码中必须要有互斥行为,当一个线程正在临界区中执行的时候,不允许其他线程进入该临界区。
  2. 如果多个线程同时要求执行临界区的代码,并且当前临界区并没有线程在知行,那么只允许一个线程进入临界区。
  3. 如果该线程不再临界区中执行,就不能阻止其他线程进入临界区。

其实本质就是需要一把锁将临界区资源锁起来,当我们需要操作的时候只有一个线程可以获取锁。锁是一个很普遍的需求,当然用户可以自己实现保护临界区,可是并不容易,Linux提供了互斥量。

互斥量的接口

互斥量的初始化
POSIX提供了两种初始化互斥量的方法:
第一种是将PTHREAD_MUTEX_INITALIZER赋值给定义的互斥量

pthread_mutex_t mutex = PTHREAD_MNUTEX_INITALIZER

如果互斥量是动态分配的,或者需要设定互斥量的属性,那么上面静态初始化方法就不适用了,NPTL提供了其他的函数进行初始化
int pthread_mutex_init(pthread_mutex_t *restrict mutex,const pthread_mutexattr_t *restrict attr);
第一个参数是操作的句柄,第二个参数是互斥量的属性,初始化的时候没有加锁
互斥量的销毁
在确定不用互斥量的时候,就要销毁它,在销毁之前,有三点要注意:

  1. 使用PTHREAD_MUTEX_INITALIZER初始化的互斥量无需销毁
  2. 不要销毁一个已经加锁的互斥量,或者是真正配合条件变量的互斥量
  3. 已经销毁的互斥量要确保后面不会再有线程尝试加锁

互斥量的销毁接口如下:
nt pthread_mutex_destroy(pthread_mutex_t *mutex);
当互斥量已经处于加锁状态的时候会返回EBUSY错误码
加锁与解锁
关于加锁解锁pthread提供了下面的接口:
int pthread_mutex_lock(pthread_mutex_t *mutex);
在调用pthread_mutex_lock的时候,可能会遭遇以下几种情况

  • 互斥量处于未锁定的状态,该函数会将互斥量锁定同时返回成功
  • 发起函数调用的时候,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_mutex_lock会陷入阻塞等待互斥量解锁。

在等待的过程中,如果互斥量持有线程解锁互斥量,可能发生如下事件:

  1. 函数调用线程是唯一的等待着,获得互斥量,成功返回
  2. 函数调用不是唯一的等待着,但成功获取互斥量,返回
  3. 函数调用不是唯一的等待着,但没有获得互斥量,继续阻塞,等待下一轮

如果在调用pthread_mutex_lock线程的时候,之前已经有一把琐了,那么就可能产生下面的情况

  1. 发生死锁,调用线程永久阻塞,线程组其他现场也无法拿到锁
  2. 返回EDEADLK
  3. 如果是内部有引用计数的锁那就允许再次加锁

临界区大小

现在我们已经意识到要用锁来保护临界变量,不过临界区设定大小已经是一个问题,如果太小了起不到保护的目的,临界区太大代码不能并发,不能利用多cpu的优势,所以在面试的时候经常有如何实现一个线程安全的搜索树这类问题,其实我们临界区设定大小就很关键。

互斥量的性能

对于互斥量来说,虽然实现了线程安全,不过每一次操作都太花费时间,对互斥量的加锁和解锁操作本身就有一定的开销,临界区的代码不能并发执行并且进入临界区的次数太过于频繁线程之间对于临界区的争夺太过激烈,若线程竞争互斥量失败,就会陷入阻塞,让出CPU,所以执行上下文切换次数要远远多于不使用互斥量的版本。
在Linux中,互斥量实现采用了fitex机制,传统的同步手段,在进入临界区之前会申请锁,而此时不得不再次执行系统调用,查看是否需要唤醒正在等待锁的进程,但是在竞争并不激烈的情况下,加锁和解锁会有可能出现下面的情况

  • 申请锁的时候,执行系统调用,从用户模式进入内核模式,却发现没有竞争
  • 释放锁的时候,执行系统调用,从用户模式进入内核模式,尝试唤醒正在等待锁的继承,却发现没有进程正在等待锁的释放。

其实这两种情况都及其耗费资源,劳而无功,所以futex机制就对于这两中做出了优化处理。在futex实现的互斥量中,值lock,它的不同状态可以优化上面的两种情况

  • lock为0,表示互斥量没有上锁
  • lock为1,表示互斥量上锁了,但是没有线程在等待该锁
  • lock为2,表示互斥量已经上锁,并且有线程正在等待该锁

加锁的时候,如果发现该值是0,那么直接将该值改为1,无需执行任何系统调用,因为并没有线程持有该锁,无需等待。解锁的时候,如果发现值为1,直接将值改为0.无需任何系统调用,因为没有线程正在等待该锁,无需唤醒。
上面是没有竞争的理想状态,下面说明如何处理多线程竞争的情况。
内核提供了futex_wait与futex_wake两个操作
futex_wait是用来协助加锁的,当线程调用pthread_mutex_lock,如果发现锁的值不是0,就会调用futex_wait告诉内核,线程需要等待在uaddr对应的锁上,将线程挂起,内核会建立与uaddr地址对应的等待队列。
如果整个系统有很多这种互斥量,我们也不需要建立很多等待队列,只要一个就够了,当线程释放锁子的时候,futex只需要调用futex_wake,内核去遍历队列找uaddr地址上的线程并且唤醒。
但是只有一个队列效率还是低,作为优化内核实现了多个队列,插入等待队列的时候,会先计算hash值,根据hash值插入相应链表。其实futex_wake就是用来解锁的,当一个线程释放锁的时候,内核需要通知正在等待该锁的线程,futex_wake的第二个参数就是要唤醒的线程个数。

互斥锁的公平性

首先要定义什么是公平,对于线程竞争资源来说,先来的先获取到锁就是较为公平的,要实现指令级的公平是很难得,常见的判断公平的办法是将锁的实现分为门廊区与等待区两个部分,门廊区必须在有限的操作内完成,等待区可能有无穷多的步骤,他们会陷入未知结束时间的等待之中。
互斥量也有门廊区与等待区,如果没有竞争那么只需要几个指令就完成了,只要门廊区就够了,可是要是多线程的话在门廊区判断出需要竞争,线程获取不到锁,就必须执行fetux_wait将其他线程挂在等待队列的队尾,这么看起来还是公平的因为维护了一个队列将后来的放在队尾,可是当我们将lock()的值从2改为0的瞬间来了一个线程还是会发生门廊区的竞争。这个太复杂了先不提…我们真正要面对的问题是不是被唤醒就可以自动获取互斥锁,反而要执行while()中包裹的cmpxchg操作,再次竞争互斥量,如果竞争失败还是会挂入队尾,所以互斥量并不是一个公平的锁,其实专家们也知道不公平,甚至写了论文…可是实现这种公平要牺牲很大的性能,所以实现大体的公平就可以了。

其他类型的互斥锁

其实互斥锁还有很多类型,除了默认类型之外还有很多变种:

  • PTHREAD_MUTEX_NORMAL:最普通的一种互斥锁,我们一直讨论的就是这种,不过不具备死锁检测功能,要是线程再次对自己加锁就死锁了。
  • PTHREAD_MUTEX_RECURSIVE_NP:支持递归的一种互斥锁,该互斥锁维护着一个计数器,第一次获取到该锁会将计数置为1,每一次自己再加锁就加加计数,释放的时候减减计数,减到0的时候就释放锁子
  • PTHREAD_MUTEX_ERRORCHECK_NP:支持死锁检测的一种互斥锁,互斥量内部会记录互斥锁当前所有者的线程ID,所以这样就保证了不会一个线程再次获取该锁
  • PTHREAD_MUTEX_ADAPTIVE_NP:这种锁子超级牛皮,libc的文档里将他称作fast mutex所以可以看出他特别的快,一般来说一个线程竞争失败会立刻调用futex_wait进入队列等待,可是竞争这种锁子的线程一般不睡眠,他竞争失败了就继续获取锁,直到成功为止,对于临界区较小的很适合,不然不肯睡去一直申请也很不好…可是自旋锁也有很大的弊端,要是一个操作不好获取锁子的线程不释放自旋接近于死循环,然后CPU使用率飙高,所以一般这种锁子适合临界区很小并且没有系统调用,没有sleep等精小的线程调用。可是有没有一种锁子可以集合普通锁子和自旋锁的优点呢,其实我们现在正在介绍的这个锁子就是集合了两种的优点,他先是和自旋锁一样不进行睡眠一直尝试获取,获取了_spins变量的时长后进行睡眠,让出CPU资源。

死锁和活锁

对于互斥量而言,一般死锁最简单的模型就是第一个线程获取了A锁,第二个线程获取了B锁,然后第一个线程尝试申请B锁,第二个线程尝试申请A锁,造成死锁,可是一般工程中涉及很多锁子和很多线程的超级死结一样的死锁,避免死锁最简单的办法就是总是按照一定得先后顺序申请,其实Linux提供了两个接口来避免死锁
int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
第一个是尝试获取,没获取到的话就返回EBUSY错误码然后线程再次睡眠,第二个是提供了一个时间,在这个时间内尝试获取,没有成功的话就睡眠。可是这样产生了新的问题,第一个线程拥有A尝试申请B,第二个线程拥有B尝试申请A,然后他俩都失败然后释放自己的锁子,排入队尾进行下一轮等待,就好像两个人互相让路最后发生堵塞。

读写锁

很多时候,对于临界变量的操作是只读的,然而只读不会改变临界变量,只有极少数情况下才会发生写的情况,所以pthread提供了读写锁,当无锁的时候读写请求都是OK的,当上了读锁的时候,当要写的时候就会阻塞,当上了写锁的时候读写都将阻塞。

读写锁的接口

读写锁的初始化以及销毁
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock,const pthread_rwlockattr_t *restrict attr);
同样的读写锁也有两种初始化,第一种是PTHREAD_RWLOCK_INITIALIZER赋值方式初始化,这种方式不用在释放锁,还有一种就是上面的函数初始化,第一个参数是句柄,第二个参数是属性,默认属性是读者优先。
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
用这个函数接口销毁用函数初始化的读写锁。
读写锁的加锁和解锁
读写锁也被叫做共享独占锁,共享一般体现在读锁上,独占一般体现在写锁上。关于上锁的接口读锁和写锁都提供了两个。
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock);
这个是读锁的上锁接口
int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
这个是写锁的上锁接口,我们可以看出读写锁和互斥锁都提供了trylock的接口,这个是系统优化死锁的一种很重要的方式,如果获取不到锁就返回错误码而不是阻塞等待,避免死锁。
读锁用于共享模式,如果当前读写锁已经被被占有,那么如果某线程要获取写锁还是可以获取到,如果当前读写锁被某线程以写的方式获取了那么即使一个线程以读获取也不能获取。
写所是独占模式,如果当前读写锁被某线程以写模式占有,则不允许任何形式获取锁的请求,读锁请求和写锁请求都会陷入阻塞。
这里关于读写锁的性能要说明一点,读写锁在很多情况下都是读锁优先,我们也可以理解,因为读锁是共享模式,一旦获取写锁所有读锁写锁请求都要被阻塞,可是一直读锁优先可能会出现写锁被饿死的情况,所以只有一种情况是写锁优先,一旦获取一个获取写锁的请求后,该请求后面到来的读锁请求到不能排到写锁请求之前。

读写锁总结

从宏观上看,读写锁要比互斥量并发性好,因为读写锁在更多时间区域内允许并发,可是读写锁也有很多缺点:

  1. 性能来说,日过临界区较大,读写锁并发优势就会显示出来,如果临界区太小,读写锁的性能短板就会显示出来,不管是加锁还是解锁,首先都要执行互斥操作,而且还要维护当前读者线程的个数,写锁和读锁的等待线程数,开销不会小于互斥锁
  2. 可能会发生饿死的情况,互斥量虽然不是绝对的公平可能会唤醒后竞争失败再次进入队列可是不会发生饿死,在读者优先的状态下写者可能被饿死。
  3. 可能发生死锁,读锁是可重入的,如果A持有了读锁,B申请了写锁正在等待,而持有读锁的A再次申请写锁,就会发生死锁。

读写锁比较适合的场景是临界区比较大,绝大多数情况只有读的场景。

条件变量

条件变量是线程间可用的另一种同步机制,条件变量给多个线程提供了一个会和的场所,条件变量和互斥量一起使用的时候,允许线程以无竞争的方式等待特定的条件发生。条件本身是由互斥量保护的,线程在改变条件状态之前首先锁住互斥量,其他线程在获得互斥量之前不会察觉到这种改变,因为互斥量必须在锁定以后才能计算条件。

条件变量的接口

条件变量不是一个值,我们无法给他赋值,一个线程要等待某个事件发生或者某个条件满足,那么这个线程需要条件变量:线程等待在条件变量上。
条件变量的创建和销毁
和互斥锁一样,使用之前要先初始化,条件变量的初始化也有静态初始化和函数初始化两种,静态初始化不用销毁。
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
上面是静态初始化的方法
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict attr);
这个是函数初始化的接口,第一个参数是操作句柄,第二个设置属性,默认为NULL
int pthread_cond_destroy(pthread_cond_t *cond);
这个是条件变量的销毁接口对于条件变量的创建与销毁,需要注意的是:

  1. 永远不要用一个条件变量给另一个赋值,这种行为是未定义的
  2. 使用静态初始化不需要被销毁
  3. 不能引用已经销毁的条件变量,这种行为是未定义的

条件变量的使用

条件变量一般都是和互斥量绑定使用的,一般来说线程会对一个条件进行判断,要是不满足就阻塞等待,并且这个时候我们就可以理解为什么等待的API需要锁子的句柄,因为解锁在获取锁子并不是一个原子操作,我们需要保证阻塞的线程解锁后其他线程获取锁子在改变临界变量,然后条件改变,阻塞的线程获取互斥量继续工作。
我们来看看条件变量等待的API
int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值