pthread多线程:传入参数并检查 data race


在这里插入图片描述

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 的补充。本篇不做具体展开,后续会介绍。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值