来自“小林coding的图解系统”
多线程同步
我们知道同一个进程中的线程可以共享地址空间,也即:线程可以共享代码段,数据段,堆空间和打开的文件资源等。当然每个线程也都有自己独立的栈和寄存器空间。
当多个线程竞争共享空间时,如果不进行一定的控制,会发生什么呢?
会造成共享数据的混乱。
举例:
#include<iostream>
using namespace std;
#include<thread>
int i = 0; //共享数据
//线程函数:对共享变量i自增1执行10000次
void test() {
int num = 10000;
for (int n = 0; n < num; n++) {
i = i + 1;
}
}
int main() {
cout << "start all threads." << endl;
//创建线程
thread thread_test1(test);
thread thread_test2(test);
//等待线程执行完成
thread_test1.join();
thread_test2.join();
cout << "All thread joined." << endl;
cout << "i = " << i << endl;
return 0;
}
按理说结果应该是20000,但是在Linux下运行的结果小于20000;
原因就是:
当想要给 i + 1
的时候,汇编指令执行的过程需要有三步:
![](https://img-blog.csdnimg.cn/352f3dc6e4fb4325a74e569b2ea74498.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBA5aaC5q2k6Imv5Lq6,size_10,color_FFFFFF,t_30,g_se,x_16)
如果此时从内存获取的i = 50
,线程1在第二步执行完成的时候(此时i = 51
,但是还未放回内存中去),突然发生了时钟中断,开始执行线程2,同样先从内存获取i = 50
,假设线程2将三步都执行完成了(i = 51
并放回内存中去),然后又发生上下文切换,回到线程1,然后接着线程1中的第二步继续执行下一步,将i = 51
放回内存中,此时全局变量i
再次被设置为51
。
概念
互斥的概念
因:当多线程相互竞争操作共享变量的时候,由于运气不好,发生了上下文切换,从而得到了错误的结果。
果:
互斥:保证一个线程在临界区执行时,其他线程应该被阻止进入临界区。
临界区:访问共享资源的代码段(例如:上例的test函数),不能给多线程同时执行。
同步的概念
因:多线程的执行时无顺序,各自独立的,以不可预知的速度向前推进,但是有时我们期望线程之间相互合作,能共同完成一个任务。
例如:
![]()
好比:操作A应在操作B之前执行,操作C必须在操作A和操作B都完成之后才能执行。
果:并发的线程在一些关键点上可能需要互相等待与互通消息,这种相互制约的等待与互通信息称为进程/线程同步。
互斥与同步的实现和使用
实现方法:有两种
- 锁:加锁、解锁操作
- 信号量:P、V操作
锁
用锁实现互斥
任何想进入临界区的线程,必须先加锁,若加锁顺利方可进入临界区,完成临界区后解锁。
根据锁的实现方式可以分为两种:忙等待锁、无忙等待锁
忙等待锁
使用的前提:需要用到原子操作指令——测试和置位指令(Test-And-Set)
Test-And-Set的代码实现:
int TestAndSet(int *old_ptr, int new_i) {
int old = *old_ptr;
*old_ptr = new_i;
return old;
}
实现逻辑:把old_ptr
更新为new_i
, 返回old_ptr
的旧值。
注意上述代码是原子执行(要么全部执行,要么都不执行)。
忙等待的实现:利用Test-And-Set来实现。
typedef struct lock_t{
int flag;
} lock_t;
void init(lock_t *lock){ //初始化
lock->flag = 0;
}
void lock(lock_t *lock){ //加锁
while(TestAndSet(&lock->flag, 1) == 1);
//do somthing;
}
void unlock(lock_t *lock){ //解锁
lock->flag = 0;
}
实现过程:
忙等待锁也被称为自旋锁
在单处理器上需要抢占式的调度器。
无等待锁
无等待锁就是当前线程没获得锁的时候,不用自旋,将当前线程放到锁的等待队列中,将CPU让给其他线程使用。
无等待锁的实现:
type struct lock_t{
int flag;
queue_t *q; //等待队列
}lock_t;
void init(lock_t *lock){
lock->flag =0;
queue_init(lock->q);
}
void lock(lock_t *lock){
while(TestAndSet(&lock->flag, 1) == 1){
1. 保存现在运行线程的TCB;
2. 将现在运行的线程TCB插入到等待队列;
3. 设置该程为等待状态;
4. 调度程序
}
}
void unlock(lock_t *lock){
if(lock->q != NULL){
1. 移出等待队列的队头元素;
2. 将该线程的TCB插入到就绪队列;
3. 设置该线程为就绪状态;
}
lock->flag =0;
}
信号量
通常信号量表示资源的数量。对应的变量是一个整型(sem
)变量。
采用两个原子操作的系统调用函数来控制信号量。
- P操作:将
sem
减1,sem<0 ? 线程进入阻塞等待 : 继续
- V操作:将
sem
加1,sem<=0 ? 唤醒一个等待中的线程 : 继续
P操作是用在进入临界区之前,V操作是用在离开临界区之后,这两个操作是必须成对出现的。
PV操作的实现
type struct sem_t{
int sem; //资源个数
queue_t *q; //等待队列
}sem_t;
void init(sem_t *s, int sem){ //初始化信号量
s->sem = sem;
queue_init(s->q);
}
//P操作
void P(sem_t *s){
S->sem--;
if(s->sem < 0){
1. 保留调用线程CPU现场;
2. 将该线程的TCB插入到s的等待队列;
3. 设置该线程为等待状态;
4. 执行调度程序;
}
}
//V操作
void V(sem_t *s){
s->sem++;
if(s->sem <= 0){
1. 移出s等待队列首元素;
2. 将该线程的TCB插入就绪队列;
3. 设置该线程为「就绪」状态;
}
}
有点和无忙等待类似,都是采用等待队列的方式。
PV操作的函数是由操作系统管理和实现的,所以操作系统已经使得执行PV函数时是具有原子性的。
PV操作的使用
信号量实现临界区的互斥访问
将每类的共享资源的信号量sem
设初值为1,表示该临界区未被占用。
信号量实现事件同步
设置信号量sem
,初值设为0.
代码实现同步的例子:
semaphore s1 = 0; //表示不需要吃饭
semaphore s2 = 0; //表示饭还没做完
//儿子线程函数
void son(){
while(TRUE){
肚子饿;
V(s1); //叫妈妈做饭
P(s2); //等待妈妈做完饭
吃饭;
}
}
//妈妈线程函数
void mon(){
while(TRUE){
P(s1);//询间需不需要做饭
做饭;
V(s2); //做完饭,通知儿子吃饭
}
}
实现说明: