多线程的数据竞争问题

摘要

在Redis的Bio代码中, 需要使用for循环创建两个服务线程, 并且把两个整数0 1作为参数传到线程执行的函数中. 这个问题涉及到在linux c中, 用for循环创建多个线程并传参数时会遇到的数据竞争问题. 本文给出该问题的分析, 几段相关的错误代码的分析, 并结合redis的BIO模块代码给出总结.

第一种错误的写法

我们的目标是在一个for循环里面,调用pthread_create函数创建线程,并且把循环用到的整数i作为参数传递,希望用这个i作为线程的标志, 首先来看一段经典的错误代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <stdio.h>
#include <pthread.h>
#define THREAD_NUM 16

void *thread_func(void *arg) {
int v = *(int*)arg;
printf("v = %d\n", v);
return (void*)0;
}

int main(int argc, const char *argv[]) {
pthread_t pids[THREAD_NUM];
int i;
for (i = 0; i < THREAD_NUM; i++) {
pthread_create(&pids[i], NULL, thread_func, (void*)(&i));
}
for (i = 0; i < THREAD_NUM; i++) {
pthread_join(pids[i], NULL);
}
return 0;
}

我们这段代码能创建16个线程,然后分别传i的地址作为参数,希望能够打印出0-15这16个数字。但是通过运行我们发现,打印出来的数字有重复的,其中一次的运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
v = 1
v = 6
v = 3
v = 4
v = 2
v = 8
v = 8
v = 9
v = 9
v = 10
v = 11
v = 12
v = 13
v = 14
v = 15
v = 0

可以看到, 8出现了好几次, 而有些数字没有出现. 这是因为, 在pthread_create这个函数执行的时候, 会把指向i的指针作为参数传递给线程调用的函数, 然后各个线程开始执行. 如果主线程的代码, 也就是我们的for循环执行比较快, 会出现如下的错误情况:

比如当前i=5, 使用pthread_create创建了线程5. 这时候, 我们期望这个线程能够从我们传的指针中取出5, 并且打印5. 但是如果主线程执行更快, 进入下一个循环, i变成了6, 此时上一个循环创建的线程才开始读取这个值,那么该线程读取的值就是6,而不是我们期望的5,这就出现了错误.
上面的错误原因在于,我们创建一个线程的时候,线程的代码什么时候开始执行不受我们的控制.

第二种错误的写法

出现上面的问题的原因在于,主线程会不断更新i的值,而i这个值会被多个线程共享, 但是多个线程何时读取这个共享的i, 是不确定的. 为了解决这个问题, 直观的想法就是, 传递i的时候, 进行数据复制, 让每个传入的指针都指向一个不一样的位置, 这样就避免了数据竞争, 代码如下:

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
#include<pthread.h>
#include<unistd.h>
#include<stdio.h>
#define THREAD_NUM 16

void *thread_func(void *arg) { int v = *(int*)arg;
printf("v = %d\n", v);
return (void*)0;
}

void init_job() {
pthread_t pids[THREAD_NUM];
int index[THREAD_NUM];
for(int i=0;i<THREAD_NUM;i++){
index[i] = i;
pthread_create(&pids[i],NULL,thread_func,(void*)(&index[i]));
}
}

int main(){
init_job();
sleep(1);
return 0;
}

这段代码避免了竞争, 但是依然不能打印0-15这16个值, 会出现重复值, 甚至一些未定义的值. 这个错误的原因在于,init_job函数执行完了以后, index数组会被收回, 这样我们传递的指针就会指向非法的位置, 当然就不能打印正确的值了. 解决这个问题的方法也很简单, 就是把index数组定义成全局变量. 完成这步以后, 我们的示例代码就可以完成预期的功能了.

第一种正确的写法

经过上面的分析, 给出基于全局变量的第一种写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include<pthread.h>
#include<unistd.h>
#include<stdio.h>
#define THREAD_NUM 16
int index[THREAD_NUM];
void *thread_func(void *arg) {
int v = *(int*)arg;
printf("v = %d\n", v);
return (void*)0;
}
void init_job() {
pthread_t pids[THREAD_NUM];
for(int i=0;i<THREAD_NUM;i++){
index[i] = i;
pthread_create(&pids[i],NULL,thread_func,(void*)(&index[i]));
}
}
int main(){
init_job();
sleep(1);
return 0;
}

第二种正确的写法

上面的做法需要额外开辟数组, 比较麻烦, 一种更加简洁的方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>
#include <pthread.h>
#define THREAD_NUM 16

void *thread_func(void *arg) {
int v = (int)arg;
printf("v = %d\n", v);
return (void*)0;}

int main(int argc, const char *argv[]) {
pthread_t pids[THREAD_NUM]; int i;
for (i = 0; i < THREAD_NUM; i++) {
pthread_create(&pids[i], NULL, thread_func, (void*)(unsigned long)(i));
}
for (i = 0; i < THREAD_NUM; i++) {
pthread_join(pids[i], NULL);
}
return 0;
}

我们传入的不是一个指向i的指针, 而是直接传i. 以64位系统为例, i是一个int型且为正数, 占用4Byte.void* 占用8byte. 所以先转化成unsigned long 类型, 然后强制转化成void*类型. 在函数传递参数的时候, 直接复制这个指针类型的值. 这个void*本身并不指向一个有效的位置, 但是其值和原始的整数i是一致的. 所以在函数thread_func中,使用int v = (int)arg就可以获得i类型. 由于这是一个值的复制而不是指针, 就不存在数据竞争的问题.

另外, 在c++中, 上面的写法可以修改成int v = (uintptr_t)arg. 这是一个和指针大小相同的unsigned int类型. 或者改成int v = static_cast(reinterpret_cast(arg)).

Redis Bio的做法

Ridis的bio中需要有两个后台线程处理任务, 其用for循环创建了两个线程, 并且也是将一个整型传入线程函数, 其相关代码如下.

创建线程的代码

1
2
3
4
5
6
7
8
9
for (j = 0; j < BIO_NUM_OPS; j++) {
void *arg = (void*)(unsigned long) j;
if (pthread_create(&thread,&attr,bioProcessBackgroundJobs,arg) != 0) {
serverLog(LL_WARNING,"Fatal: Can't initialize Background Jobs.");
exit(1);
}
bio_threads[j] = thread;
}

线程内部的处理

1
2
3
4
5
6
void *bioProcessBackgroundJobs(void *arg) {
struct bio_job *job;
unsigned long type = (unsigned long) arg;
sigset_t sigset;
......
}

可以看到, Redis的Bio中使用了上述的第二种解决方案。

总结

使用for循环结合pthread_create创建多个线程, 并且传递循环中使用的变量i作为参数的时候,会遇到数据竞争的问题, 本文介绍了问题出现的原因, 以及两种解决方案. 一种方法是依然传递指针, 并是做数据的拷贝, 使得多线程没有共享数据; 另一种是直接利用指针的空间来传递整数, 把指针当成整数来用,而不传递指针. 其中第二种方法在Redis的Bio代码中得到使用.

相关文献

[1] stackoverflow/questions/39117674/

[2] Begin linux programming 4th edithon NeilMatthew chapter 12

[3] Redis 官网

[4] stackoverflow/questions/332030