同步机制(一)
什么是同步机制?
为什么需要同步机制?
当计算机只运行一个线程的时候,自然不需要同步。所有的资源都是这个线程独享。那么就不会有任何竞争。
但是当计算机出现了多个线程的时候,那么就出现了各种麻烦,为了处理这些麻烦我们就需要使用一些办法来解决这些麻烦。
多线程引出的麻烦(对资源的竞争导致的出错) :
设想存在A,B两个线程。对同一数据C进行修改。首先A读取数据C,得知C=13。这时候发生了线程切换。切换为B,然后B读取C,得知C=13。然后修改C,将C减少1,最后保存C。这时候C为12。执行结束后,又切换回A,A将C增加1,但是A得知的C是13,于是C=13+1=14。保存C。这时候C=14。
将上述例子代入生活中来说 :首先A本来是从存钱罐里塞进去了一块钱,B拿出了一块钱。但是最终结果确实C从13元变成14元了。莫名其妙多了一块钱了!
如何实现同步机制?
原子操作 : 原子操作的意思就是不可切割,不可打断的操作。
CPU层面的原子操作 : 对于CPU而言,一条机器指令(机器指令和汇编指令是一一对应的关系,所以后面的例子我会采取汇编指令代替机器指令)就是一个原子操作。因为中断随时有可能发生,但只会发生在两句机器指令之间,而不会在一句机器指令执行到一半就发生,这是不被允许的(被动的进程切换就是通过时钟中断处理程序来实现的)。
CPU是如何实现原子操作的?
多种情况分析:
例1) 多核CPU对自己的寄存器进行修改 : 不存在公共资源的竞争,因为寄存器是CPU私有的(每个CPU都有自己的寄存器),其它的CPU无法直接访问。
例2) 单核CPU对自己的寄存器进行修改 : 同上
例3) 单核CPU对内存(公共变量)进行修改 : 因为只有一个CPU,一个内存,也就可以理解为内存是CPU私有的。
例4) 多核CPU对同一地址的物理内存(公共变量)进行修改 :
这时候会存在公共资源的竞争,我们可以简单地把CPU修改内存的内容分为三步 :
1) 读取对应内存上的内容
2) 用内容进行计算
3) 将结果写入对应内存
cpu0的任务是将内存1111的值+1:inc word ptr ds:[1111]
cpu1的任务是将内存1111的值-1 : dec word ptr ds:[1111]
那么就回到了我们之前提到的例子 : 多线程引出的麻烦(对资源的竞争导致的出错)
什么是总线锁 : 就是使用处理器提供的一个LOCK信号,当一个处理器在总线上输此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占共享内存。
解决策略 : 在进行操作 1)之前,先使用总线锁锁定这段内存(例如 : 1111 ~ 1113)。使得其他CPU无法对该内存操作,当操作 3) 结束,就释放这段内存的总线锁。
那么CPU层面的原子操作的支持也就形成了 : 一条机器指令就是一个原子操作。
软件层面的原子操作 :
原子整数 : 既然机器指令允许直接对内存进行算术运算,那么直接设置一个宏或函数,使用汇编操作该变量即可达成原子整数了。
原子整数的加法代码实现(任意平台都能运行,这个本来就不需要头文件):
int add (int * pval, int num){ int old; __asm__ volatile( // volatile : 修饰内嵌汇编时表示不要优化指令 "lock; xaddl %2,%1;" // %1 += %2 : "=a" (old) // =表示是输出参数,a表示rax寄存器 : "m" (*pval) , "a"(num) // m 表示内存变量, a表示rax寄存器 : "cc", "memory" ); return old; }
虽然看上去这并不是一句汇编,但实际上只有 : lock xaddl %2,%1,这句汇编是临界区,也就是说,这句话锁上了内存,然后将pval的虚拟地址对应的物理地址上的数据增加了num。后面的 return old,只不过把最后结果返回,但是实际上返回值为Null也完全不会影响,因为早已经通过指针修改了那块地址的内容了。
原子操作的使用(任意平台都能运行,这个本来就不需要头文件):
int add (int * pval, int num){ int old; __asm__ volatile( // volatile : 修饰内嵌汇编时表示不要优化指令 "lock; xaddl %2,%1;" // %1 += %2 : "=a" (old) // =表示是输出参数,a表示rax寄存器 : "m" (*pval) , "a"(num) // m 表示内存变量, a表示rax寄存器 : "cc", "memory" ); return old; } int main (){ int c = 1; add(&c, 100); // printf("c : %d\n",c); return 0; }
在这里仅仅提供了单线程的使用,实际上多线程也是这么使用的。
在多线程模式下的运行(在Linux平台下可运行):
# include<stdio.h> # include<unistd.h> # include<pthread.h> #define THREAD_COUNT 10 /* 关于 volatile的本意是“易变的” 因为访问寄存器要比访问内存单元快的多,所以编译器一般都会作减少存取内存的优化,但有可能会读脏数据。 当要求使用volatile声明变量值的时候,系统总是重新从它所在的内存读取数据,即使它前面的指令刚刚从该处读取过数据。 精确地说就是,遇到这个关键字声明的变量,编译器对访问该变量的代码就不再进行优化,从而可以提供对特殊地址的稳定访问; 如果不使用valatile,则编译器将对所声明的语句进行优化。 与 register 相反。 */ /* 内嵌汇编语法如下 : __asm__(汇编语句模板: 输出部分: 输入部分: 破坏描述部分) 共四个部分:汇编语句模板,输出部分,输入部分,破坏描述部分,各部分使用":"格开,汇编语句模板必不可少,其他三部分可选,' 如果使用了后面的部分,而前面部分为空,也需要用":"格开,相应部分内容为空。 例如: __asm__ __volatile__("cli": : :"memory") */ int add (int * pval, int num){ int old; __asm__ volatile( // volatile : 修饰内嵌汇编时表示不要优化指令 "lock; xaddl %2,%1;" // %1 += %2 : "=a" (old) // =表示是输出参数,a表示rax寄存器 : "m" (*pval) , "a"(num) // m 表示内存变量, a表示rax寄存器 : "cc", "memory" ); return old; } void* thread_callback(void *arg){ int * pcount = (int *)arg; int i = 0; while(i++ < 100000){ // (*pcount)++; add(pcount,1); usleep(1); } } int main (){ pthread_t threadid[THREAD_COUNT] = {0}; // 初始化 int i = 0; int count = 0; // 创建线程 for (i = 0; i < THREAD_COUNT; i++){ pthread_create(&threadid[i], NULL, thread_callback, &count);// thread_callback, &count : 函数与其参数 } for (i = 0; i < 10; i++){ printf("count : %d\n", count); sleep(1); } return 0; }
【推荐】还在用 ECharts 开发大屏?试试这款永久免费的开源 BI 工具!
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步