1. 线程安全问题的来源之一 —— 对共享资源进行非原子操作的修改
使用两个线程执行 Counter 函数,对 count 变量分别自加 100 万次,最后结果为 1049042,并非 200 万。
原因在于 count++ 操作并非原子操作。count++ 操作可拆解为 3 步:int temp = count,count = temp + 1,return temp。线程 1 执行完第一步,还没执行第二步时,线程 2 可能此时已经开始执行第一步,即 temp = count,故两个线程拿到的 temp 值相同。然后两个线程执行 count = temp + 1,加了两次,但 count 的值仅增加了 1。
对共享资源进行非原子操作的修改,是线程安全问题的来源之一。
#include <stdio.h>
#include <tinycthread.h>
int count = 0;
int Counter(void *arg){
for (int i = 0; i < 1000000; ++i) {
count++;
}
return 0;
}
void TestCounterByThread(){
thrd_t t_1;
thrd_t t_2;
// create two threads to run the Counter function
thrd_create(&t_1, Counter, NULL);
thrd_create(&t_2, Counter, NULL);
// free up resources
thrd_join(t_1, NULL);
thrd_join(t_2, NULL);
printf("count: %d", count); // count: 1049042
}
2. 线程安全问题的来源之二 —— 共享资源的可见性
线程 A 对某个共享资源做了修改,线程 B 不一定能够立刻获取到修改的内容。修改内容需要通过 CPU 缓存同步到内存中,B 才能获取到该修改内容。
在 T1 函数将 a 赋值为 2,flag 赋值为 1。在 T2 函数判断 flag 值并求解 a*a。理论上说,该代码可能陷入死循环,也可能计算出的 x 值为 0。原因在于可能 T1 函数执行 a = 2; flag=1; 后,改为 T2 函数执行,此时 T2 函数获取的 flag 和 a 值可能并没有改变,仍然为 0 和 0。但实际情况还与编译器内部实现有关,上述情况是理论结果。
#include <stdio.h>
#include <tinycthread.h>
int flag = 0;
int a = 0;
int x = 0;
int T1(void *arg){
a = 2;
flag = 1;
return 0;
}
int T2(void *arg){
while (flag == 0){}
x = a * a;
return 0;
};
void TestVisibilityByThread(){
thrd_t t_1;
thrd_t t_2;
// create two threads to run the Counter function
thrd_create(&t_1, T1, NULL);
thrd_create(&t_2, T2, NULL);
// free up resources
thrd_join(t_1, NULL);
thrd_join(t_2, NULL);
printf("a: %d ", x); // count: 1049042
}
int main(){
int count = 0;
while (count < 1000000){
TestVisibilityByThread();
count++;
}
return 0;
}
3. 线程安全问题的来源之三 —— 代码重排序
在 CMakeLists.txt 文件中添加 set(CMAKE_C_FLAGS "-O3"),可以让编译器对汇编指令进行优化。而这种优化是保证单个线程内部逻辑不变的情况下,对编译器进行的优化。而对多个线程间的逻辑,则无法保证。
查看代码的汇编指令进行验证。现修改 2 小节的部分代码,查看其汇编指令。
int T1(void *arg){
a = 2;
flag = 1;
a = a + 3;
return 0;
}
未优化前的汇编指令如图所示,可以发现 a = 2, a = a + 3 两个操作对应的指令是分开的。
而优化后的汇编指令如下图所示, a = 2, a = a + 3 两个操作对应的指令被编译器优化为了一条指令,a 直接被赋值为 5,不存在赋值为 2 的可能性。
此外,对 2 小节的 T2 函数代码,观察 while 循环对应的未优化前的汇编指令。首先获取 flag 的值,然后 test flag,如果为 0 就跳到 L4,如果不为 0 就继续向下执行。
优化后的汇编指令如下图所示,首先获取 flag 的值,然后判断 flag 是否为 0,如果不是 0 继续执行,如果是 0 跳到 L5。注意此时指令中出现了死循环,和先前未优化前相比,优化后的指令只对 flag 判断了一次,而不是先前对 flag 循环进行判断。
此时修改 2 节的部分代码,可让程序执行结果为死循环。注意,修改后的代码使用未优化指令不会产生死循环的结果。两者差异的原因在于 flag 是否被循环进行判断。
// create two threads to run the Counter function
thrd_create(&t_2, T2, NULL);
thrd_create(&t_1, T1, NULL);
优化指令程序执行结果:
未优化指令程序执行结果:
4. 线程安全问题的产生
1)对共享资源进行非原子的并发访问
2)不同线程之间的代码可见性问题
3)线程内部代码编译时的重排序问题