updated:

《程序员的自我修养》- 线程安全


竞争与原子操作

程序控制流执行的最小单位是指令,如果一个操作有多条指令组成,该操作进行中就有可能因为时间片用尽而被打断。更糟糕的是,如果接下来的线程改变了之前操作所需要的数据,将会产生意料之外的后果。一个典型的例子就是自增和自减操作,例如下面的程序:

1
2
3
4
5
6
7
8
9
10
volatile int a;
void func(void)
{
a++;
}
int main(void)
{
func();
return 0;
}

该程序的反汇编代码如下:

注意在原程序中对a使用了volatile修饰符,即“每次使用a时都从内存读取,这模拟了最坏的情况。其反汇编代码也展示了这种情况:从0x01129到0x01132的三条指令都是为自增这一个操作服务的。这意味着如果程序流刚好在0x0112f之前被打断,并且打断期间对a做了自减操作,那么当本线程再次运行时,之前的自减就相当于白费力气了。 所谓“原子操作”便是解决这一问题的好方法,原子操作指单指令的操作。以自增为例,它将原本完成操作需要的三条指令替换为了一条指令,即inc指令。这样就不会出现上面提到的问题了。 然而显而易见的是,我们不可能将所有操作都变成原子操作,这时我们就需要一个更为强大且通用的机制来保证线程的安全了。

同步与锁

所谓同步,即在一个线程完成对数据的访问之前,其他线程不得访问该数据,类似于一种阻塞的概念。为了在不同的情境下实现同步,需要一些特别的机制,即锁。锁分为以下几种类型:

信号量

一个初始值为N的信号量允许N个进程同时访问,可以理解为访问数据的名额为N。线程访问由信号量保护的数据时的流程如下:

需要注意N的值反映出等待队列有多长,当N大于0时,任何新的线程都可以加入,因此等待队列为0。特别的,当信号量的初始值为1时,这个资源同时只允许一个线程访问,这种信号量称为二元信号量。

互斥锁

与信号量不同的是,使用互斥锁的数据一次只能允许一个线程使用,而且上锁和解锁的必须是同一个线程,强调线程的独占。下面我们使用一个例子来直观地看待互斥锁造成的影响:

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
#include <pthread.h>
#include <stdio.h>
#include <unistd.h>
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
int shared_data = 0;
void *print_msg(void *arg)
{
int i = 0;
//pthread_mutex_lock(&mutex);
for(i = 0; i < 10; i++) {
printf("shared_data: %d i: %d\n", shared_data, i);
shared_data++;
usleep(100);
}
//pthread_mutex_unlock(&mutex);
return NULL;
}
int main(int argc,char** argv)
{
pthread_t id1;
pthread_t id2;
pthread_create(&id1, NULL, print_msg, NULL);
pthread_create(&id2, NULL, print_msg, NULL);
pthread_join(id1, NULL);
pthread_join(id2, NULL);
pthread_mutex_destroy(&mutex);
return 0;
}

当用于加互斥锁的语句被注释掉时,对该程序各个线程的时间进行分析,结果如下:

可以看到,两条线程可以近似认为是轮流执行的,而输出结果也印证了这一点,加上互斥锁后结果如下:

区别相当明显。注意这个例子只是直观解释了互斥锁所带来的影响,不代表互斥锁的正确使用方法。

从上面的例子中,我们可以注意到一个有趣的细节:图中的这些子线程大部分时间是处于阻塞状态的,如图:

其中红色的runnable指就绪,蓝色的running指执行中,灰色的blocked指阻塞中,通过这张图片也可以直观地看到线程在不同状态之间的相互转化。

临界区

首先应该明确的是,临界区是一段用来访问竞争资源的代码,它是一种轻量且高效的线程同步实现。也正因此,临界区的作用范围仅限于本进程。在同一个进程中,临界区的行为和互斥锁类似。

读写锁

读写锁专门适用于多线程读写同一数据的场合。由于无法保证对数据的写操作是原子操作,所以需要有一种方式保证数据在写操作完成之前不被读取。读写锁有三种状态,即自由状态,独占状态(单一进程写)和共享状态(多进程读)。线程获取读写锁的几种可能性如下所示:

条件变量

条件变量通常搭配互斥锁使用,其作用是实现“等待-唤醒的流程”,下面是一个条件变量的使用场景:线程A可以提供线程B所需要的数据,因此需要保证线程A在线程B之前运行。下面我们使用例子来说明条件变量的作用: 假设我门又一个生产者线程,不断增加变量product。同时还有一个消费者线程,在product达到一定量时拿走全部的product。如果仅仅使用互斥锁实现的话,生产者线程和消费者线程应该分别是这样的:

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
#include <pthread.h>
#include <stdio.h>
#include <unistd.h>
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
int product = 0;
void *producer(void *arg) // 生产者
{
for (int i = 0; i < 1000; ++i) {
pthread_mutex_lock(&mutex);
product++;
pthread_mutex_unlock(&mutex);
usleep(100);
}
return NULL;
}
void *consumer(void *arg) // 消费者
{
for (int i = 0; i < 100; ++i) {
pthread_mutex_lock(&mutex);
if (product >= 10) {
printf("Got what I want.\n");
product = 0;
} else {
printf("I'm not satisfied.\n");
}
pthread_mutex_unlock(&mutex);
}
return NULL;
}
int main(int argc, char** argv)
{
pthread_t id1;
pthread_t id2;
pthread_create(&id1, NULL, producer, NULL);
pthread_create(&id2, NULL, consumer, NULL);
pthread_join(id1, NULL);
pthread_join(id2, NULL);
pthread_mutex_destroy(&mutex);
return 0;
}

然而我们在分析该程序时,看到的各个线程所占用的时间却是这样的:

可以看到,消费者在检测product是否满足需求时,会进行大量的轮询操作,频繁占用生产者线程的时间,同时大量的加解锁操作也会影响程序的性能。为了解决这一问题,我们可以引入条件变量,改进后的程序如下:

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
#include <pthread.h>
#include <stdio.h>
#include <unistd.h>
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
int product = 0;
void *producer(void *arg)
{
for (int i = 0; i < 1000; i++) {
pthread_mutex_lock(&mutex);
product++;
if (product >= 10) {
pthread_cond_signal(&cond); // 唤醒等待中的进程
}
pthread_mutex_unlock(&mutex);
usleep(100);
}
return NULL;
}
void *consumer(void *arg)
{
for (int i = 0; i < 100; i++) {
pthread_mutex_lock(&mutex);
pthread_cond_wait(&cond, &mutex); // 开始等待,互斥锁解锁,结束等待(signal函数被调用)时上锁
if (product >= 10) {
printf("Got what I want.\n");
product = 0;
} else {
printf("I'm not satisfied.\n");
}
pthread_mutex_unlock(&mutex);
}
return NULL;
}
int main(int argc,char** argv)
{
pthread_t id1;
pthread_t id2;
pthread_create(&id1, NULL, producer, NULL);
pthread_create(&id2, NULL, consumer, NULL);
pthread_join(id1, NULL);
pthread_join(id2, NULL);
pthread_mutex_destroy(&mutex);
pthread_cond_destroy(&cond);
return 0;
}

各线程占用时间如下:

提升是显而易见的,现在我们的消费者线程由于条件变量的作用,在不满足一定条件的情况下就不会进入运行状态,如此一来,程序执行流变得更加清晰,性能也得到了提升。

关于锁的一点看法

上面所提到的各种“锁”只是方法,而并非目的,每一种锁都有其适用范围和特性。在实践中,我们更应该注重因地制宜,也别忘了灵活运用。比如互斥锁和信号量,两者虽然都具有保护资源的功能,但实际上互斥锁更偏向于“保护”,强调独占性;信号量更为灵活,它更多的用于流程控制,强调顺序性。在线程安全方面,上面的内容只是理论上的介绍,实际的使用方法还是依赖于大量的实践。

可重入

重入即当一个函数正在执行时由于外部因素或内部调用又一次进入该函数执行,这可能是因为多个线程在同时执行这个函数,也有可能是因为递归。 如果一个函数被重入后不会产生任何不良后果,那么这个函数就是可重入的。在开发多线程程序时,需要根据实际需求对函数进行调整,以避免因重入产生意料之外的后果。


← Prev 数据结构 - Lesson 1 | 《程序员的自我修养》- 线程 Next →