登 录
注 册
< 系统运维
Linux
计算机系统
系统工具
系统硬件组成
高速缓存
存储器及操作系统
Amdahl定理
信息表示和处理
内存有关错误
全球IP因特网
信号量同步线程
热门推荐>>>
中台架构
中台建设与架构
Hadoop
源码分析-NN启动(三)
HBase
HBased对接Hive
Linux
Nginx高可用
Python
数据导出工具
Flink
3分钟搭建Flink SQL测试环境
Kafka
Kafka对接Flume
深度学习
卷积神经网络
数据结构与算法
选择合适的算法
MySQL
数据备份恢复
Hive
Hive调优参数大全
其他框架
Azkaban Flow1.0与2.0
ClickHouse
表引擎-其他类型
技术成长
最好的职业建议
精选书单
技术成长书单—机器学习
技术资讯
数据在线:计算将成为公共服务
开发工具
IntelliJ IDEA 20年发展回顾(二)
系统工具
Mac命令行工具
虚拟化
内存虚拟化概述
云原生
云原生构建现代化应用
云服务
一文搞懂公有云、私有云...
Java
Spring Boot依赖注入与Runners
Go
Go函数与方法
SQL
SQL模板
安全常识
一文读懂SSO
当前位置:
首页
>>
计算机系统
>>
信号量同步线程
信号量同步线程
2020-07-05 14:16:14 星期日 阅读:1708
![](/static/images/article_images/1693753488.501173.jpeg) 多线程并发编程共享变量是十分方便的,但是它们也引入了同步错误(synchronization error)的可能性。考虑到下面的程序badcnt.c,它创建了两个线程,每个线程都对共享计数变量cnt加1。 `一个同步错误的计数器程序` ``` /* WARNING: This code is buggy! */ #include "csapp.h" void *thread(void *vargp); /* Thread routine prototype */ /* Global shared variable */ volatile long cnt = 0; /* Counter */ int main(int argc, char **argv) { long niters; pthread_t tid1, tid2; /* Check input argument */ if (argc != 2) { printf("usage: %s <niters> ", argv[0]); exit(0); } niters = atoi(argv[1]); /* Create threads and wait for them to finish */ Pthread_create(&tid1, NULL, thread, &niters); Pthread_create(&tid2, NULL, thread, &niters); Pthread_join(tid1, NULL); Pthread_join(tid2, NULL); /* Check result */ if (cnt != (2 * niters)) printf("BOOM! cnt=%ld ", cnt); else printf("OK cnt=%ld ", cnt); exit(0); } /* Thread routine */ void *thread(void *vargp) { long i, niters = *((long *)vargp); for (i = 0; i < niters; i++) cnt ++; return NULL; } ``` 因为每个线程都对计数器增加了niters次,我们预计它的最终值是2 * niters。这看上去简单而且直接。然而,当在Linux系统上运行badcnt.c时,我们不仅得到错误的答案,而且每次都得到的答案都不相同! ``` linux> ./badcnt.c 1000000 BOOM! cnt=1445085 linux> ./badcnt.c 1000000 BOOM! cnt=1915220 linux> ./badcnt.c 1000000 BOOM! cnt=1404746 ``` 那么哪里出错了呢?为了清晰地理解这个问题,我们需要研究计数器循环(第40至41行)的汇编代码。我们发现,将线程i的循环代码分解成五个部分是很有帮助的。 ``` Hi:在循环头部的指令块; Li:加载共享变量cnt到累加寄存器%rdx的指令,这里%rdx表示线程i中的寄存器%rdx的值; Ui:更新(增加)%rdxi的指令; Si:将%rdxi的更新值存回到共享变量cnt的指令; Ti:循环尾部的指令块。 ``` 注意头部和尾部只操作本地栈变量。而Li/Ui/Si操作共享计数器变量的内容。 当badcnt.c中的两个对等线程在一个单处理器上并发运行时,机器指令以某种顺序一个接一个地完成。因此,每个并发执行定义了两个线程中的指令的某种全序(或者交叉)。不幸的是,这些顺序中的一些将会产生正确的结果,但是其他的则不会。 这里有个关键点:一般而言,你没有办法预测操作系统是否将为你的线程选择了一个正确的顺序。例如,图12-18a展示了一个正确的指令顺序的分步操作。每个线程跟新了共享变量cnt之后,它在内存中的值就是2,这正是期望的值。 ####信号量 Edsger Dijkstra,并发编程领域的先锋人物,提出了一种经典的解决同步不同执行线程问题的解决方法,这种方法是基于一种叫做信号量(semaphore)的特殊类型变量的。信号量s是具有非负整数值的全局变量,只能由两种操作来处理,这两种操作分别为P和V: `P(s)`:如果s是非零的,那么P将s减少1,并理解返回。如果s为零,那么就挂起这个线程,直到s变为非零,而一个V操作会重启这个线程在重启之后,P操作将s减1,并将控制返回给调用者。 `V(s)`:V操作将s加1。如果有任何线程阻塞在P操作等待s变成非零。那么V操作会重启这些线程中的一个,然后将该线程减1,完成它的P操作。 旁注:P和V名字的起源: Edsger Dijkstra出生于荷兰,名字P和V来源于荷兰语单词Proberen(测试)和Verhogen(增加). P中的测试和减1操作是不可分割的,也就是说,一旦预测信号量s变为非零,就会将s减1,不能有中断。V中的加1操作也是不可分割的,也就是加载、加1和存储信号量的过程没有中断。注意,V的定义中没有定义等待线程被重启动的顺序。唯一的要求是V必须只能重启一个正在等待的线程。因此,当有多个线程在等待同一个信号量时,你不能预测V操作要重启哪一个线程。 `P和V的定义确保了一个正在运行的程序绝不可能进入这样一种状态,也就是一个正确初始化了的信号量有一个负值。这个属性称为信号量不变性`。为控制并发程序的轨迹线提供了强有力的工具。 Posix标准定义了许多操作信号。 ``` #include <semaphore.h> int sem_init(sem_t *sem, 0, unsigned int value); int sem_wait(sem_t, *s); /* P(s) */ int sem_post(sem_t, *s); /* V(s) */ // 返回:若成功则为0,若失败则为-1 ``` sem_init函数将信号量sem初始化为value。每个信号量在使用前必须初始化。针对我们的目的,中间的参数总是零。程序分别通过调用sem_wait和sem_post函数来执行P和V操作。为了简明,我们更喜欢使用下面的这些等价的P和V的包装函数: ``` #include "csapp.h" void P(sem_t *s); /* Wrapper function for sem_wait */ void V(sem_t *s); /* Wrapper function for sem_post */ // 返回:无 ``` ####使用信号量来实现互斥 信号量提供了一种很方便的方法来确保对共享变量的互斥访问。基本思想是将`每个共享变量(或者一组相关的共享变量)与一个信号量s(初始为1)联系起来,然后用P(s)和V(s)操作将相应的临界区包围起来`。 以这种方式来保护共享变量的信号量叫做二元信号量(binary semaphore),因为它的值总是0或者1。以提供互斥为目的二元信号量常常也称为`互斥锁(mutex`)。在一个互斥锁上执行P操作称为堆互斥锁加锁。类似地,执行V操作称为对互斥锁解锁。对一个互斥锁加了锁但是还没有解锁的线程称为占用这个互斥锁。一个被用作一组可用资源的计数器信号量被称为计数信号量。 因为禁止区完全包括了不安全区,所以没有实际可行的轨迹线能够接触不安全的任何部分。因此,每条实际可执行的轨迹线都是安全的,而且不管运行时指令顺序是怎样的,程序都会正确地增加计数器值。 从可操作的意义上来说,由P和V操作创建的禁止区使得在任何时间点上,在被包围的临界区中,不可能有多个线程在执行指令。换句话说,信号量操作确保了对临界区的互斥访问。 总的来说,为了用信号量正确同步本节最开始的示例程序badcnt.c中的计数器示例,我们首先声明一个信号量mutex: ``` volatile long cnt = 0; /* Counter */ sem_t mutex; /* Semaphore that protects counter */ ``` 然后在主例程中将mutex初始化为1: ``` Sem_init(&mutex, 0, 1); /* mutex = 1 */ ``` 最后,我们通过把在线程例程中对共享变量cnt的更新包围P和V操作,从而保护它们: ``` for (i = 0; i < niters; i++) { P(&mutex); cnt ++; V(&mutex); } ``` 当我们运行这个正确同步的程序时,现在它每次都能产生正确的结果了。 ``` linux> ./goodcnt 1000000 OK cnt=2000000 linux> ./goodcnt 1000000 OK cnt=2000000 ``` ####进度图的局限性 进度图给了我们一种较好的方法,将在单处理器上的并发程序执行可视化,也帮助我们理解为什么需要同步。然而,它们确实也有局限性。特别是对于在多处理器上的并发执行,在多处理器上一组CPU、高速缓存对共享同一个主存。`多处理器的工作方式是进度图不能解释的`。特别是,一个多处理器内存系统可以处于一种状态,不对应与进度图中任何轨迹线。不管如何,结论总是一样的:`无论是在单处理器还是多处理器上运行程序,都要同步你对共享变量的访问。`