关于并发线程临界区数据安全的问题

关于并发线程临界区数据安全的问题

这题超纲了 柳筋

关于并发线程临界区数据安全的问题

众所周知,线程是共享同个内核的,并且用户区的一些资源也是共享的(除了栈区和.text区);那这就意味着堆区的数据以及全局变量存储的.bss和.data区都是共享的,那么当多个线程操作共享数据的时候,就会出现问题了
如:

代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
//用多线程实现卖票的案例
//有三个窗口,一共有100张票,3个窗口并发地卖100张票
#include <iostream>
using namespace std;
#include <pthread.h>
#include <unistd.h>
int i=100;
void *Sellticket(void *)
{
while(i>0)
{
usleep(6000);
cout<<pthread_self()<<" "<<"卖出的票:"<<i<<endl;
i--;
}
return NULL;
}
int main()
{
//创建3个子线程,子线程用来卖票
pthread_t pthid1,pthid2,pthid3;
pthread_create(&pthid1,NULL,Sellticket,NULL);
pthread_create(&pthid2,NULL,Sellticket,NULL);
pthread_create(&pthid3,NULL,Sellticket,NULL);

//回收子线程地资源
pthread_join(pthid1,NULL);
pthread_join(pthid2,NULL);
pthread_join(pthid3,NULL);

//退出主线程
pthread_exit(NULL);
return 0;
}
//出现线程无法同步地问题

此时就会出现下面这种情况

那么为什么会出现这种情况呢?

我们就以上面的代码为例:

1.假设在程序一开始运行的时候,A线程开始执行卖票,此时A线程就会对临界区的资源进行修改,那么此时A线程就会进行循环卖票(Sellticket),此时CPU的资源正在被A线程占用。
2.但是由代码可知,每次一个线程在进入while开始卖票前,都会先睡眠6000us(usleep),虽然这个时间非常短,短到我们人类根本不会察觉到,但也是存在的,所以此时A线程就会进行睡眠从而使得CPU空闲出来,此时就会有B线程抢占到CPU。虽然这种事发生的概率很低,但大家都知道墨菲定律叭,小概率的事情是一定会发生的。
3.假设A线程已经卖了99张票了,此时的票就只有一张了(i=1),此时A线程要去卖最后一张票了,所以A线程进入while循环里,于是会先睡眠6000us,那么此时小概率事情就发生了,CPU被线程B抢夺过去了。

小知识1

我们要知道,如果一个线程没执行完之前因为某种异常而导致无法占有CPU的时候,会有一个寄存器会专门记录睡眠前的状态,等该线程获得CPU资源的时候就会根据寄存器的内容而继续之前没执行完的代码

4.B线程抢到CPU后又会usleep一次,假设此时A线程还在睡!那么此时CPU就会被C线程抢占过去,同理,C线程又又又睡了…
5.此时A线程苏醒了,那么A线程就会继续刚刚没执行完的代码,也就是卖票!那么(i–)票卖完了,按道理来说,票已经卖完了,那么就不可能有票可以卖了对吧,此时就出现了一个问题,不过在此之前我们需要先知道

小知识2

了解汇编指令的执行顺序

6.所以,全局变量(i),也就是票数已经为0了,此时B线程苏醒了…,
7.B线程会先输出当前票数(i),也就是票数为0,但是怎么可能卖出0张票呢对吧,现实中没有就是没有了,所以这就是一个问题。那为什么还会输出-1呢?
8.因为此时C线程也拿到CPU了,因为C线程已经进入while循环了,所以不会判断循坏条件(i>0,B线程也没判断),又因为B线程之前执行了一次(i–),所以(i==-1),所以才会输出-1。
那么这就是出现了数据安全问题了捏。

如何避免出现线程安全的问题呢?

自旋锁

“自旋”可以理解为“自我旋转”,这里的旋转是指循环,例如while或者for循环;自旋就是不停的循环直到达到退出循环的条件。

简易代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
typedef struct lock_t
{
int flag;
}lock_t;

int TestAndSet(int *old_flag,int new_flag)
{
int old=*old_flag;
*old_flag=new_flag;
return old;
}
void init(lock_t *lock)
{
lock->flag=0;
}

void lock(lock_t *lock)
{
while(TestAndSet(lock->flag,1)==1)
{
//开始自旋
}
}

void unlock(lock_t *lock)
{
lock->flag=0;
}

如何理解:

场景1

一开始锁没有线程拿,那么此时锁的flag=0,假设有一个线程拿到了锁,会进行加锁(lock),此时TestAndSet函数返回的值为0,不满足循环条件,并且将锁的flag置1。释放锁的时候(unlock),又会将锁的flag置0,表示现在锁没有线程拿。

场景2

假设有一个线程已经拿到了锁并且没有释放,那么此时锁的flag==1,此时另外一个进程想要拿锁,那么就会执行lock函数,也就会执行TestAndSet函数,此时传入的参数为(&1,0),得到的值也只会是0,满足循环条件,那么就会一直循环,直到拿锁的线程把锁释放了,那么此时flag==0,传入的参数就变成(&0,0)了,此时就不满足循环条件,也就会跳出循环并且拿到锁

这是最简单的一种锁,一直自旋,利用 CPU 周期,直到锁可用。在单处理器上,需要抢占式的调度器(即不断通过时钟中断一个线程,运行其他线程)。否则,自旋锁在单 CPU 上无法使用,因为一个自旋的线程永远不会放弃 CPU。

优点

有些场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复现场的花费可能会让系统得不偿失。如果机器有多个CPU核心,能够让两个或以上的线程同时并行执行,我们就可以让后面那个请求锁的线程不放弃CPU的执行时间,看看持有锁的线程是否很快就会释放锁。为了让当前线程“稍等一下”,我们需让当前线程进行自旋,如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销。

缺点

它最大的缺点就在于虽然避免了线程切换的开销,但是它在避免线程切换开销的同时也带来了新的开销,因为它需要不停得去尝试获取锁。如果这把锁一直不能被释放,那么这种尝试只是无用的尝试,会白白浪费处理器资源。也就是说,虽然一开始自旋锁的开销低于线程切换,但是随着时间的增加,这种开销也是水涨船高,后期甚至会超过线程切换的开销,得不偿失。

互斥锁

互斥锁与自旋锁不同的点就在于当一个线程拿不到互斥锁的时候,这个线程不会进行自我循环一直等待并且占用CPU资源,而是阻塞,CPU去执行其他线程

互斥锁的一些API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//互斥锁类型
pthread_mutex_t

//初始化互斥锁
int pthread_mutex_init(pthread_mutex_t *restrict mutex,const pthread_mutex_t *attr)
参数:
pthread_mutex_t *restrict mutex:需要初始化的互斥锁,restrict是C语言修饰符,被修饰的指针,不能由另一个指针进行操作;
pthread_mutex_t *attr:互斥量相关的属性,一般使用NULL(默认属性);
返回值:
成功返回0,失败返回-1
//摧毁互斥锁
int pthread_mutex_destroy(pthread_mutex_t *mutex);
参数:要摧毁的互斥锁
返回值:同上

//加锁,这个函数是阻塞的,如果有一个线程加锁了,那么其他的线程就只能阻塞等待
int pthread_mutex_lock(pthread_mutex_t *mutex)
参数:要加的锁
返回值:同上
//尝试加锁
int pthread_mutex_trylock(pthread_mutex_t *mutex)
与上面不同的是,如果加锁失败的话是不会阻塞的,而是直接返回
//解锁
int pthread_mutex_unlock(pthread_mutex_t *mutex)

自旋锁又被叫做“忙等待锁”,互斥锁又叫“无等待锁”,只要用了锁就一定没问题吗?其实并不是这样的,用锁的时候必须注意出现死锁的问题,什么是死锁?

死锁

所谓死锁,大概意思就是指某个锁一直被一个线程占用而不解锁,那么此时无论哪个线程都无法再继续访问被锁上的资源了,那么这个锁就是死锁

出现死锁的情况

1.加了锁后忘记释放锁,此时就会出现无论是持有锁的线程还是其他想要访问被锁上的资源的线程都会被阻塞,那么这个锁就是死锁

2.有时,一个线程需要同时访问两个或者多个不同的共享资源,而每个资源又都由不同的互斥锁来管理。当超过一个线程加锁同一组资源的时候,就有可能发生死锁

3.类似两个人过独木桥,双方都不愿意让步,那么双方就永远过不去了;举个栗子:A线程拿了A资源的锁,然后又想要访问B资源,B线程拿了B资源的锁,并且它想要访问A资源,那么此时就出现了冲突,如果A线程想要访问B资源,那么就必须等B资源的锁被解开,也就是B线程必须先解开B资源的锁,但是B线程解开B资源的锁的前提条件是先拿到A资源的锁,但是A资源的锁又被A线程拿了,想要解开A资源的锁,就必须先让A线程拿到B资源的锁。如此下去就会陷入一个死循环,那么此时就会出现死锁。

读写锁

互斥锁只要有一个线程拿到,那么其他线程无论是读取还是修改被锁上的数据都没办法做到,只能乖乖阻塞等到解锁,但是实际上呢,如果这个线程只是在对被上锁的资源进行读操作,那么其他线程只是想要读取数据并不会导致出现线程安全问题,所以就出现了读写锁

读写锁的特点

1.如果有一个线程读数据,则允许其他线程执行读操作,但不允许进行写操作

2.如果一个线程进行数据的时候,其他线程都不允许进行读,写操作

3.写是独占的,写的优先级最高

读写锁的一些API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//读写锁的类型
pthread_rwlock_t
读写锁和互斥锁很像

//初始化读写锁
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock,const pthread_rwlock attr_t * restrict attr);

//摧毁读写锁
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);

//加读锁
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);

//加写锁
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);

//解锁
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);

悲观锁和乐观锁

悲观锁

所谓悲观锁,就是很悲观的认为不加锁的话一定会出现线程临界区的数据安全问题,所以在操作临界区的数据前就对数据加锁,像互斥锁,自旋锁,读写锁都属于悲观锁

乐观锁

但其实我们知道,数据出现问题毕竟属于小概率事件,所以乐观锁就是很乐观的认为不会出现这种数据安全问题,只有等到出现这种问题的时候再进行加锁,像现在很多都是使用乐观锁,就比如群文档,一个人在操作的时候另外一个人也可以对文档进行操作。毕竟如果一个人在操作文档的时候另外一个人没办法操作的话,会降低很多很多效率

其实不光是锁可以保护临界区数据的安全,也有其他方法,例如条件变量信号量等,同时在满足互斥的时候,我们也需要满足同步

同步和互斥

互斥:像上面说的数据在上锁后就只能被一个线程进行操作,其他线程没办法获取到被上锁的资源,就被叫做互斥
同步:但我们知道,线程之间虽然是独立运行的,但有时候我们需要多个线程之间互相合作去完成一些事情
例子,线程 1 是负责读入数据的,而线程 2 是负责处理数据的,这两个线程是相互合作、相互依赖的。线程 2 在没有收到线程 1 的唤醒通知时,就会一直阻塞等待,当线程 1 读完数据需要把数据传给线程 2 时,线程 1 会唤醒线程 2,并把数据交给线程 2 处理。
所谓同步,就是并发进程/线程在一些关键点上可能需要互相等待与互通消息,这种相互制约的等待与互通信息称为进程/线程同步。

下一篇我们好好讲一下信号量,因为它不光能满足线程间的互斥,还同时支持线程间的同步,同时也会列出几个模型来帮助大家更好地掌握线程间的同步和互斥

  • 标题: 关于并发线程临界区数据安全的问题
  • 作者: 这题超纲了
  • 创建于: 2023-02-24 18:28:08
  • 更新于: 2023-06-23 14:38:22
  • 链接: https://qx-gg.github.io/2023/02/24/blog4/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
推荐阅读
关于线程池的构建及理解 关于线程池的构建及理解 信号量以及一些互斥同步模型 信号量以及一些互斥同步模型 浙江宇视科技一面 浙江宇视科技一面
 评论