关于APUE中多线程编程的条件变量相关内容
bigfly Lv4

LINUX环境下多线程编程肯定会遇到需要条件变量的情况,此时必然要使用pthread_cond_wait()和其他发信号的函数函数,然而在了解这部内容的过程中,有些疑惑。

一、代码

下面是笔者在学习APUE的过程中使用多线程相关知识实现对指定范围内的质数的筛选,并在屏幕上打印出找到的质数。

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
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
#include <string.h>

// 通过池类算法,和 互斥量也就是锁相关的知识实现了,固定区间的质数的筛选。
// 重点内容:质数筛选的方法,以及 临界区代码 的跳转存在的问题,
// 查询法,降低cpu的占用率

#define LEFT 30000000
#define RIGHT 30000200
#define THRNUM 4

static int num = 0;
static pthread_mutex_t mut_num = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t cond_num = PTHREAD_COND_INITIALIZER;

static void *thr_prime(void *p);

int main() // main线程
{
int err;
pthread_t tid[THRNUM];
for (size_t i = 0; i < THRNUM; i++)
{
err = pthread_create(tid + i, NULL, thr_prime, (void *)i); // NULL表示默认属性,thr_prime表示线程要做的事情
if (err)
{
fprintf(stderr, "pthread_create():%s\n", strerror(err));
exit(EXIT_FAILURE);
}
}

// 下发任务
for (size_t i = LEFT; i <= RIGHT; i++)
{
pthread_mutex_lock(&mut_num);

// 循环判断任务有没有被抢走
while (num != 0) // 任务没有被抢走,分配数的时候 ,三个池在抢,一旦抢走将其设置为0
{
pthread_cond_wait(&cond_num, &mut_num);//期待num变为0,谁会把num变为0呢?

}
num = i; // 放任务

pthread_cond_signal(&cond_num); // 叫醒 下游任何一个就行,而不是叫醒全部,这里叫醒任意一个闲的,若全部叫醒用broadcast

pthread_mutex_unlock(&mut_num); // 解锁
}

pthread_mutex_lock(&mut_num);

//*** for循环完成后,当前线程若又接到调度,那么不加下面的while 将会使最后一个任务被覆盖掉
while (num != 0)
{
pthread_mutex_unlock(&mut_num);
sched_yield();
pthread_mutex_lock(&mut_num);
}

num = -1;// num =-1 说明没任务了,

pthread_cond_broadcast(&cond_num);
pthread_mutex_unlock(&mut_num); // 解锁

for (size_t i = 0; i < THRNUM; i++)
{
pthread_join(tid[i], NULL); // 收尸
}

pthread_mutex_destroy(&mut_num);
pthread_cond_destroy(&cond_num);
exit(EXIT_SUCCESS);
}

static void *thr_prime(void *p)
{
int j, mark, i;

while (1)
{ // 临界区

pthread_mutex_lock(&mut_num);
while (num == 0)
{
pthread_cond_wait(&cond_num, &mut_num); // 解锁等待num变成非零,谁会把num变为非零呢?这里是查询法,而不是一直循环

}

if (num == -1)
{
// 临界区跳转,且跳转的目的地是临界区外,一定要先解锁再跳转。否则会出现死锁。
pthread_mutex_unlock(&mut_num);
break; // 总任务完成
}

i = num;

num = 0;
pthread_cond_broadcast(&cond_num); // 将num设置为0 ,发送一个通知
pthread_mutex_unlock(&mut_num);

mark = 1;
for (j = 2; j < i / 2; ++j)
{
if (i % j == 0)
{
mark = 0;
break;
}
}
if (mark)
printf("[%d]%d is a primer\n", (int)p, i);
}
pthread_exit(NULL);
}

二、疑惑点: pthread_cond_broadcast与pthread_mutex_unlock的先后顺序问题

chatGPT给出的答案:

在多线程编程中,pthread_mutex_unlockpthread_cond_broadcast 的执行顺序通常是没有明确要求的,因为它们操作的是不同的同步原语:互斥锁和条件变量。

网上大多数的代码给出的答案:

先pthread_cond_broadcast再pthread_mutex_unlock

笔者个人的思考总结:

  1. 先pthread_cond_broadcast再pthread_mutex_unlock会出现的问题:这样做pthread_cond_broadcast处在临界区内,只有当pthread_mutex_unlock解锁之后,其他子线程才有机会加锁,加锁成功的线程才能cond_wait收到广播信号,倘若在pthread_cond_broadcast与pthread_mutex_unlock之间还有很多代码需要执行,那此刻pthread_cond_broadcast尽管发送了信号,但其也是阻塞状态,只有当unlock之后其他子线程才能抢锁然后cond_wait收到cond_broadcast发送的信号。
  2. 先pthread_mutex_unlock 再pthread_cond_broadcast会出现的问题:通知时机问题: ( 如果先解锁再广播,有可能在解锁后,条件变量的等待线程还未来得及执行。这样,等待线程可能错过了通知,从而导致它们继续等待而不被唤醒。从而使程序出现不期望的结果) 竞态条件: (如果在解锁互斥锁后,调用 pthread_cond_broadcast 之前有其他线程抢占执行,并尝试获取互斥锁进行修改,那么这些修改可能与 pthread_cond_broadcast 同时发生,导致不一致的状态。意思就是,若存在刚解锁还没来得及broadcast时,当前线程被操作系统调度出CPU,而其他线程刚好被调度并尝试获取相同的互斥锁,此时线程就有机会在广播之前获取互斥锁并修改共享资源。接着,执行广播的线程再次获取互斥锁,执行广播,但但有线程已经修改了共享资源,这可能导致不一致的状态,产生竞态条件。)

总结:

先解锁再广播,虽然等待条件的线程能够尽早获得互斥锁然后cond_wait可以很快的收到广播信号,但是这种做法存在潜在的问题,可能会导致在广播发送信号的瞬间其他线程修改了共享资源,从而可能引起竞态条件或不一致性。而先再临界区内广播然后再解锁,这样可以避免以上潜在问题,代价就是速度变慢了,若在调用 pthread_cond_broadcast 之前,互斥锁已经被解锁。这样一来,虽然被唤醒的线程就可以尽快获得互斥锁,进入临界区执行,但是,若在调用pthread_cond_broadcast广播的时候其他线程瞬间修改共享资源,会出现错误。

尽管先unlock 再broadcast,出现错误的概率可能很低,比中彩票的概率还低,但从逻辑分析,这种问题是存在的,所以为了避免这种不期望事情发生,一般采取先broadcast 然后再unlock。

三、one more thing(求质数的巧妙方法)

这里判断质数的方法是

1
2
3
4
5
6
7
8
9
10
11
mark = 1;
for (j = 2; j < i / 2; ++j)
{
if (i % j == 0)
{
mark = 0;
break;
}
}
if (mark)
printf("[%d]%d is a primer\n", (int)p, i);

其中i是待判断的数。

质数的定义:只能分解为1和它本身两个因数

上面的做法给i/2是为了减少计算量,直接写i也没问题,但是为什么可以写i/2呢?

这是因为如果 i 有大于i/2的因子,那么它的另一个因子一定小于i/2,否则两个因子的乘积就会超过 i。因此,只需要判断 i 是否在2i/2的范围内有因子即可。

还有一种做法是,要判断到sqrt(i)即可,因为如果 i 有一个大于sqrt(i)的因子,那么它的另一个因子一定小于sqrt(i),两个因子的乘积就会超过 i。因此,循环的条件可以改为 j <= sqrt(i)。这样也可以减少不必要的循环次数,但由于要调用sqrt对应的函数库或者自己写相关的开平方函数,所以没有采用这种方法。