同步、互斥、读写锁、自旋锁、RCU锁

总结一下线程的中同步与互斥的概念
通过互斥锁实现一个生产消费模型
同时辨析读写锁、自旋锁、RCU锁

临界资源引例:售票系统

设一个全局变量int count = 100;,表示当前售票系统的剩余的票的数量
有一个线程正在执行购票模块的代码count--;

count--的执行分为三步

  • cpu从内存中读取到当前余票(100)到cpu寄存器中
  • cpu对该值进行减法操作
  • cpu得到的结果(99)写入内存

假设在第二步刚刚结束时,由于出现大量优先级更高的线程,内核保存当前线程上下文,该线程被挂起。

第二个线程执行同样代码并正常退出,(读出100写入99)
第二个线程执行同样代码并正常退出,(读出99写入98)
此时切回第一个线程,继续执行下一步:写入99到内存

经过上述操作,当前余票应为97,实为99,显然程序出现了错乱

加锁保护临界资源

引起上述问题的根本原因:减法操作不是原子性的

原子性:最小粒度的特性。对于代码来说,要么全部执行,要么都不执行

减法操作在执行中可能会被打断,是非原子性的,同时操作对象可能被打断期间被访问,最终造成混乱

为了解决上述问题,我们可以要求涉及共享变量代码段执行前首先申请执行权限
申请成功后,在执行结束前不允许其它线程申请到执行权限

这种解决方案即为:加一把「互斥锁」
同时:
对于每次仅允许一个进程访问的资源,我们称作临界资源
对于对临界资源进行操作的代码段,我们称作临界区

在Linux的这种解决方案中,这把锁称为:互斥量
通过互斥量,避免临界区的并发操作,即实现了对临界资源的保护
上述概念即为所谓常听见的「同步」与「互斥」中的互斥

互斥是为了保证线程的安全,同步则是在保证线程安全的基础上,协调各进程,使得访问临界资源具有顺序性
避免了线程不合理地频繁「申请锁」与「释放锁」带来的额外开销

互斥实例

通过引入互斥量,线程在执行临界区前需要申请锁,释放前其它线程的申请操作都会被阻塞,进而保证安全

通过一段代码实例来认识这一概念

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include <sched.h>

int ticket = 100; //全局变量:余票数
pthread_mutex_t mutex; //全局变量:互斥锁(类似信号量操作,对锁的操作是原子性的)

void *buy(void *arg)
{
char *id = (char*)arg;
while ( 1 )
{
pthread_mutex_lock(&mutex); //申请锁
if ( ticket > 0 )
{
printf("after %s brought ticket,least ticket:%d\n", id, ticket);
ticket--;
pthread_mutex_unlock(&mutex); //释放锁
}
else
{
pthread_mutex_unlock(&mutex); //释放锁
break;
}
}
}

int main( void )
{
pthread_t t1, t2, t3, t4;
pthread_mutex_init(&mutex, NULL); //初始化互斥量

pthread_create(&t1, NULL, buy, "thread 1");
pthread_create(&t2, NULL, buy, "thread 2");
pthread_create(&t3, NULL, buy, "thread 3");
pthread_create(&t4, NULL, buy, "thread 4");

pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_join(t3, NULL);
pthread_join(t4, NULL);

pthread_mutex_destroy(&mutex); //销毁锁
}

其中

  • 互斥量的初始化
    • 动态初始化int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr _t *restrict attr);
      • mutex:要初始化的互斥量
      • attr:NULL 一般使用NULL表示默认的配置
      • 成功返回0,失败返回错误码
    • 静态初始化pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
      • 需要静态初始化互斥量时使用,声明、定义、初始化一气呵成,不需要销毁
  • int pthread_mutex_lock(pthread_mutex_t *mutex);
    • 加锁
    • 如果其它线程已经lock成功,则该线程陷入阻塞,等待解锁
  • int pthread_mutex_unlock(pthread_mutex_t *mutex);
    • 解锁
  • int pthread_mutex_destroy(pthread_mutex_t *mutex);
    • 销毁互斥量,不可销毁静态初始化的或者未解锁的互斥量
    • 要确保销毁后不再加锁

互斥

在上述售票系统的例子中,如果我们加入第五个线程执行:在票数为0时设置票数为100
则会出现新的问题:

  • 在票数减至0前,线程5不断地检测票是否为0,产生不必要的开销
  • 线程5在临界区被切出去后发生饿死(一直没有切回来),1~4号线程不断地买票(失败)

那么对于这个问题,则需要协同各个线程合理地按顺序执行,即线程的同步
在本例中,我们通过「条件变量」建立一种生产消费模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include <sched.h>

int ticket = 100; //全局变量:余票数
pthread_mutex_t mutex; //全局变量:互斥锁(类似信号量操作,对锁的操作是原子性的)
pthread_cond_t cond; //全局变量:条件变量

void *product(void *arg)
{
char *id = (char*)arg;
while ( 1 )
{
pthread_mutex_lock(&mutex); //申请锁

if (ticket > 0)
{
pthread_cond_wait(&cond, &lock); //等待条件变量,
}

printf("after %s produce ticket,least ticket:%d\n", id, ticket);
ticket = 100;
pthread_mutex_unlock(&mutex); //释放锁
}
}

void *buy(void *arg)
{
char *id = (char*)arg;
while ( 1 )
{
pthread_mutex_lock(&mutex); //申请锁
if ( ticket > 0 )
{
printf("after %s brought ticket,least ticket:%d\n", id, ticket);
ticket--;
pthread_mutex_unlock(&mutex); //释放锁
}
else
{
pthread_mutex_unlock(&mutex); //释放锁
pthread_cond_signal(&cond); //唤醒
usleep(100);
}
}
}

int main( void )
{
pthread_t buyer, producter;
pthread_mutex_init(&mutex, NULL); //初始化互斥量
pthread_cond_init(&cond, NULL); //初始化条件变量

pthread_create(&buyyer, NULL, buy, "thread 1");
pthread_create(&buyyer, NULL, buy, "thread 2");
pthread_create(&buyyer, NULL, buy, "thread 3");
pthread_create(&buyyer, NULL, buy, "thread 4");
pthread_create(&producter, NULL, product, "thread 5");

pthread_join(buyyer, NULL);
pthread_join(producter, NULL);

pthread_mutex_destroy(&mutex); //销毁锁
pthread_cond_destroy(&cond); //销毁条件变量
}

其中

  • int pthread_cond_init(pthread_cond_t *cond,const pthread_condattr_t *rest rict attr);
    • cond:准备初始化的条件变量
    • attr:NULL 默认配置
  • int pthread_cond_destroy(pthread_cond_t *cond)
    • 销毁条件变量
  • int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t mutex);
    • 等待条件的满足
    • 函数一进入wait状态就会自动release mutex,退出时lock mutex
    • cond:对应的条件变量
    • mutex:相关的互斥量
  • 唤醒
    • int pthread_cond_broadcast(pthread_cond_t *cond); //唤醒所有等待线程
    • int pthread_cond_signal(pthread_cond_t *cond);

读写锁

互斥锁成功地保证了对临界资源访问的安全性,但额外会带来锁申请、释放的开销

在读写两个线程操作频次差距巨大时(且读操作频繁且耗费时间较长(伴随查找))
两个读线程不会相互影响安全,却因为互斥锁带来了额外的开销,此时可以使用读写锁

互斥锁只有两种状态,而读写锁有三种状态,以文件读写为例

  • 无锁
    • 读写都不会被阻塞
  • 读锁
    • 其它读线程可以不阻塞的并发读,写操作申请锁会被阻塞
  • 写锁
    • 单线程的写操作,任何其他线程(包括其他写线程)都在申请锁时阻塞

根据读写锁的特性,在写锁没有申请时,读线程可以高并发的操作,提升整体性能
读写锁非常适合对数据结构读的次数远远大于写的情况

思考:读线程源源不断时,写线程可能被饿死
所以读模式的线程接受到写请求时,应阻塞其后所有读线程

自旋锁

自旋锁类似于读写锁
但是保护安全的方式不是通过阻塞
而是通过 cpu忙等待(死循环轮询)的方式去等待解锁,以保护线程安全

自旋锁适用:锁被持有的时间较短,而且进程并不希望在重新调度上花费太多的成本

RCU锁

(Read-Copy Update):读-复制 更新

实际上是对读写锁的一种改进,同样对读区别对待,但对待的方式不同
读写锁仅支持多个读操作,RCU还支持多个写操作

RCU基本做法为
读操作不做限制
写操作总是在一个副本上修改,在合适的时机更新原件(所有读者都结束对临界资源的访问后,使用回调函数函数完成对原件的更新)

限于篇幅,RCU深度剖析请移步另一篇博文(TODO)

参考 https://blog.csdn.net/aganlengzi/article/details/51339519

POSIX信号量

与条件变量的对比

条件变量总是与互斥锁一起使用的
那么换一个角度:

  • 条件变量和配合互斥锁时
    • 互斥锁必须总是有给他上锁的线程解锁,信号量灵活的多
    • 互斥锁只有锁住、解开两种状态
  • 条件变量做唤醒时,如果没有线程接受信号,信号将丢失

实例

限于篇幅,请移步另一篇博文(TODO)