ZMonster's Blog 巧者劳而智者忧,无能者无所求,饱食而遨游,泛若不系之舟

将局部变量地址作为线程函数参数导致bug

问题

在测试自己写的一个使用了多线程的API时,我发现有时候会出现一些预期之外的结果。

首先,这个API的实现中涉及多线程的部分大致是这样的:

  1. 有一个全局初始化函数,循环创建多个线程,并将每个线程和一个线程号绑定(在pthread_create中作为第四个参数传入)

    int Init(int thread_num) {
        int ret = 0;
    
        pthread_t *thread_list = (pthread_t *)calloc(thread_num, sizeof(pthread_t));
    
        pthread_attr_t a;
        pthread_attr_init(&a);
        pthread_attr_setdetachstate(&a, PTHREAD_CREATE_DETACHED);
    
        for (int i = 0; i < thread_num; ++i) {
            ret = pthread_create(thread_list + i, &a, thread_func, (void *)&i);
    
            if(ret != 0) {
                return ret;
            }
        }
    
        return ret;
    }
    
  2. 不同的线程根据绑定的线程号不断查看一个全局变量数组中对应的表示任务状态的元素,当检测到状态为 READY 时就执行一些任务

    struct Task{
        int status;
        // other
    };
    
    Task tasks[MAX_TASK_NUM];
    
    void *thread_func(void *thread_id)
    {
        int i = *((int *)thread_id);
    
        int status = 0;
    
        do {
            status = tasks[i].status;
            if (status == READY) {
    
                // do something
    
                tasks[i].status = DONE;
            }
            else {
                sleep(1);
            }
        }while (status != QUIT);
    }
    
  3. 一个模块初始化函数,用来初始化处理模块,这个处理模块会做一些预处理,然后通过线程来进行最后的操作,这个初始化函数接受一个线程号作为参数

    struct Env{
        int thread_id;
        // other
    };
    
    void Prepare(Env *env, int thread_id)
    {
        env->thread_id = thread_id;
        // other
    }
    
  4. 一个模块处理函数,在初始化一些环境、进行一些预处理后,将对应线程的状态置为 READY ,然后等待直到对应线程处理完毕

    void Run(Env *env)
    {
        // do preprocessing
        int i = env->thread_id;
        tasks[i].status = READY;
    
        int status;
        do {
            sleep(1);
            status = tasks[i].status;
        } while (status != DONE);
    }
    

我遇到的问题就是,在将第 i 个线程对应的状态置为 READY 后,线程函数并没有进行相应的操作(偶尔)。

加锁

一开始,我并没有对全局变量的读写进行加锁,因此怀疑是处理函数中对状态成员变量的修改没有成功。于是我将所有对全局变量数组中状态成员的读写操作都加了锁,但是问题仍然存在。

取消编译器优化

考虑到对全局数组的状态成员的读、写操作有部分是在循环中进行的,因此怀疑gcc在编译器时对程序进行了优化,于是用 volatile 修饰了状态成员

struct Task{
    volatile int status;
    // other
};

然而这个方法仍然无效。

局部变量,循环变量

和项目组组长讨论后,他也表示暂时没发现这个问题的根源,不过他建议我将 线程函数 访问的 全局数组元素的地址处理函数 中访问的 全局数组元素的地址 打印出来对比一下。我听从了组长的建议,这样做了,然后果然发现了问题。

在出错时,这两个地址是不一样的。随后,我将 thread_func 函数与 Run 函数中的变量 i 的值打印出来,发现在出错的情况下,这两个值不相等。静下心来思考了一会后,我发现了问题的根源所在,即全局初始化函数 Init 中创建线程这一段:

for (int i = 0; i < thread_num; ++i) {
    ret = pthread_create(thread_list + i, &a, thread_func, (void *)&i);

    if(ret != 0) {
        return ret;
    }
}

pthread_create 函数接受的第四个参数是作为线程函数 thread_func 的参数的,而在这里,我将既是 局部变量 也是 循环变量i地址值 作为 pthread_create 函数的第四个参数。

在测试时,为了方便,在执行 Init 函数时,我只让它创建了一个线程,即对函数:

int Init(int thread_num);

传入的 thread_num 参数的值为 1

首先,作为循环变量, Init 中的变量 i 在循环结束时值为 1 ,而在 Run 函数中,表示线程号的变量 i 的值则为 0

其次,主线程与子线程之间运行的先后次序是不确定的。

最后, Init 函数中的变量 i 是局部变量,在 Init 函数执行完后,其对应地址的值是不能保证的。

总之这样的做法是大错特错的。在我将上述循环创建线程的代码段修改为如下内容后,本文所述的问题得到了彻底的解决:

for (int i = 0; i < thread_num; ++i) {
    tasks[i].thread_id = i;
    ret = pthread_create(thread_list + i, &a, thread_func, (void *)&(tasks[i].thread_id));

    if(ret != 0) {
        return ret;
    }
}

注意,这里的 thread_id 是在 Task 结构中新添加的 int 类型成员