文章目录
- 1. 目的
- 2. 给子线程传入参数:万能类型 `void*`
- 3. data race
- 3.1 什么是 data race
- 3.2 怎样检测 data race
- 4. data race 的例子
- 4.1 子线程传入同一个 data
- 4.2 使用栈内存
- 5. 解决 data race 问题
- 5.1 忽视问题?
- 5.2 避开同一个变量的使用
- 5.3 使用互斥锁(mutex)
- 5.4 使用条件变量 (cond var)
1. 目的
使用 pthread 创建多线程时,子线程和主线程之间、子线程和子线程之间, 很可能需要数据交互, 例如读取相同的输入, 汇总每个线程的输出, 不同线程的结果可能需要以累加方式汇总,等等。传入数据时需要了解void*
类型转换。
数据交互的类型包括读取(read)和写入(write)两种类型, 如果没有处理好, 可能导致 data race 的情况, 而 data race不一定表现出结果不正确, 或者结果正确但不会crash, 或者只有在运行了很长时间后才 crash, 甚至是极低概率的偶现crash 的问题。需要确保数据交互是安全的,也就是避免 data race, 这需要了解 ThreadSanitizer 等工具的使用。
2. 给子线程传入参数:万能类型 void*
线程函数的参数必须是 void*
类型, 因此创建线程(也就是执行 pthread_create
)时,传入的最后一个参数, 会被自动转化为void*
类型。任何类型都可以转为 void*
型:
int data = 123;
pthread_create(&t, NULL, hello, &data);
也可以手动显式转换:
int data = 123;
pthread_create(&t, NULL, hello, (void*)(&data));
而在子线程函数中, void*
可以转为任意的类型。对于 C 语言, 支持隐式转换,也就是说等号左侧需要写具体的(指针)类型、等号右侧不必写出具体类型;而对于 C++, 则需要在等号右侧显式给出类型。统一起见,我们写出既能用于C也能用于C++的类型转换写法:
void* hello(void* param)
{
int* data = (int*)param;
...
}
以下是完整能运行的代码:
//
// 创建1个线程, 创建时传入一个参数。 在线程函数中读取这个参数。
//
#include <stdio.h>
#include <pthread.h>
void* hello(void* param)
{
int* data = (int*)param;
printf("data is %d\n", *data);
return NULL;
}
int main()
{
pthread_t t;
int data = 123;
pthread_create(&t, NULL, hello, &data);
pthread_join(t, NULL);
return 0;
}
3. data race
3.1 什么是 data race
data race 中文含义是数据竞争,所谓竞争就需要至少两个对手,两个对手之间有排斥关系,以及至少一个被竞争的物品。严谨一些的定义如下:
- 存在至少两个线程(threads),它们访问同一个数据(data)
- 这些线程当中,至少有一个线程是对这个数据执行写入(write)操作
3.2 怎样检测 data race
使用神器 Thread Sanitizer
(简称TSan
) 可以检查 data race 问题。
需要当前编译器支持 TSan, 目前(2023-05-28 00:16:12)Windows 的 Visual Studio 2022 还不支持 TSan, 不过 Linux, MacOSX 平台的 GCC, CLang 是支持 TSan 的。
还需要构建时传入编译链接选项 -fsanitize=thread
.
对于 TSAN_OPTION
环境变量, 不需要额外设置。
编译出可执行程序后, 执行程序, 如果存在 data race, 会报告打印到控制台。
4. data race 的例子
4.1 子线程传入同一个 data
让每个子线程函数传入的参数, 都是同一个指针,
虽然用到的线程函数 print_message 没有写入操作, 但是其实可以写入, 仍然是危险的。
代码如下:
//
// 这是一个反面例子。
//
// 创建了多个线程, 每个线程的参数是同一个。 存在的风险: data race.
// 虽然用到的线程函数 print_message 没有写入操作, 但是其实可以写入。
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#include <stdlib.h>
void* print_message(void* param)
{
int id = *(int*)param;
printf("Hello from thread %d\n", id);
return NULL;
}
int main()
{
const int thread_num = 2;
pthread_t t[thread_num];
int* id = (int*)malloc(sizeof(int));
for (int i = 0; i < thread_num; i++)
{
// 将变量 i 赋值给 *id
// id 变量是堆内存申请的, 能否规避掉 stack-use-after-scope 和 data race?
// 答案是不能。
*id = i;
pthread_create(&t[i], NULL, print_message, id);
}
for (int i = 0; i < thread_num; i++)
{
pthread_join(t[i], NULL);
}
free(id);
return 0;
}
执行编译并运行:
zz@Legion-R7000P% clang++ multiple_thread_data_race_by_same_param.cpp -fsanitize=thread
zz@Legion-R7000P% ./a.out
TSan 用红色标出存在 data race,用蓝色标出具体的线程, 用绿色标出 race 的 data 有多大:
4.2 使用栈内存
需要首先了解栈内存(stack)和堆内存(heap)的区别,heap 内存是 malloc / new 方式申请的, 栈内存则是普通变量, 并且有显著的生命周期。
如下例子使用 for 循环中的循环变量 i, 由于 i
是 for 循环起始时定义的, 每次循环时都“活着”, 而如果每次循环把 i 的地址作为线程函数参数传入, 会导致子线程都可以修改变量 i, 导致了潜在的 data race。 代码如下:
//
// 这是一个反面例子。
//
// 创建2个线程.
// 传入线程函数的参数, 使用的是主线程的单次 for 循环的变量 i 的地址, scope 上有问题.
// 导致了 data race。 应当避免。
//
#include <stdio.h>
#include <pthread.h>
void* print_message(void* param)
{
int* data = (int*)param;
printf("data is %d\n", *data);
return NULL;
}
// 这个函数里就是错误的用法
int main()
{
const int thread_num = 2;
pthread_t t[thread_num];
for (int i = 0; i < thread_num; i++)
{
// 将变量 i 作为传给 print_message() 的变量
// 由于 i 使用的是栈内存, 不能给子线程用
// asan 会产生报告 "stack-use-after-scope"
// tsan 则会产生报告 "data race"
pthread_create(&t[i], NULL, print_message, &i);
}
for (int i = 0; i < thread_num; i++)
{
pthread_join(t[i], NULL);
}
return 0;
}
执行编译和运行, TSan 这次也报告了 data race 问题
zz@Legion-R7000P% clang++ multiple_thread_data_race_by_stack_memory.cpp -fsanitize=thread
zz@Legion-R7000P% ./a.out
5. 解决 data race 问题
5.1 忽视问题?
如果假装不知道 ThreadSanitizer 这一神器, 又或者代码是在 Windows Visual Studio、Android NDK 平台这样的不支持 Thread Sanitizer 的编译器环境下, 好像可以“自我欺骗”, 觉得“代码和人有一个可以跑就行了”。但这样无法保证程序的正确性, 风险较大。
换言之, 如果可能, 尽量写跨平台的程序, 并在 CI/CD 阶段配置不同的操作系统、编译器执行构建, 然后到支持 TSan 的平台上执行检查。
5.2 避开同一个变量的使用
结合具体的场景, 看能否使用不同的变量来作为线程的函数, 如果确实可以用不同的参数, 那就不存在 data race。
例如如下代码, 创建两个线程, 并让每个线程使用独立的参数, 从而规避 data race 问题
//
// 创建两个线程, 并让每个线程使用独立的参数, 从而规避 data race 问题
//
#include <pthread.h>
#include <stdio.h>
void* print_message(void* param)
{
int* p = (int*)param;
*p = *p + 1;
int id = *p;
printf("id is %d\n", id);
return NULL;
}
int main()
{
const int thread_num = 2;
pthread_t t[2];
int id[2];
for (int i = 0; i < thread_num; i++)
{
id[i] = i;
pthread_create(&t[i], NULL, print_message, &id[i]);
}
for (int i = 0; i < thread_num; i++)
{
pthread_join(t[i], NULL);
}
return 0;
}
5.3 使用互斥锁(mutex)
mutex 可以作为避免 data race 的一种基础的、部分有效的手段。本篇不做具体展开,后续会介绍。
5.4 使用条件变量 (cond var)
条件变量需要和 mutex 搭配使用, 相当于 mutex 的补充。本篇不做具体展开,后续会介绍。