Linux之多线程(三):线程安全、同步与互斥、条件变量

173 阅读6分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

线程安全

认识:多个线程同时操作临界资源而不会出现数据二义性,即在线程中是否对临界资源进行了非原子操作。

  • 可重入与不可重入: 多个执行流中可以同时进入函数运行而不会出现问题。 可重入不一定线程安全,但不可重入一定不安全

如何实现?同步与互斥

  • 同步:临界资源的合理访问。
  • 互斥:临界资源同一时间的唯一访问性。

互斥如何实现?通过互斥锁来实现。

互斥

互斥锁

互斥锁本质上是一个0/1计数器

  • 0表示不可以加锁,不能加锁则等待。
  • 1表示可以加锁,加锁操作就是计数-1。 操作完毕之后要解锁,解锁操作就是计数+1,并且唤醒等待

互斥锁操作步骤:

  1. 定义互斥锁变量 pthread_mutex_t
  2. 初始化互斥锁变量pthread_mutex_init()
  3. 加锁 pthread_mutex_lock()
  4. 解锁 pthread_mutex_unlock()
  5. 销毁互斥锁 pthread_mutex_destroy()

初始化互斥锁

初始化要在线程创建之前,因为线程运行的时序我们无法得知,如果在创建之后初始化,有可能已经是使用过的了,这时初始化再已为时已晚。

  1. 使用pthread_mutex_init()接口 。
int pthread_mutex_init(pthread_mutex_t *mut,pthread_mutexattr_t *attr);
  • mut: 互斥锁变量的地址。
  • attr:互斥锁的属性,通常置NULL
  1. 使用pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;直接赋值初始化(本质一个结构体)。

两种方法定义的互斥锁变量mutex不一定非要全局变量,只要保证要互斥的线程都能访问到即可。


加锁

【注】:加锁要在访问临界资源之前

  1. pthread_mutex_lock()接口:阻塞加锁(加不上锁就阻塞)
int pthread_mutex_lock(pthread_mutex_t *mutex);
  1. pthread_mutex_trylock():尝试加锁 / 非阻塞加锁(加不上锁则直接报错返回)
int pthread_mutex_trylock(pthread_mutex_t *mutex);
  1. pthread_mutex_timedlock():限时阻塞加锁 它的使用前提需要用户定义一个宏,只作知悉,有兴趣的读者请自行了解。

解锁

解锁在临界资源访问完毕,同时在线程任何有可能退出的地方都要进行解锁操作。

int pthread_mutex_unlock(pthread_mutex_t *mutex);

释放互斥锁

完全不使用之后再释放,要在join操作之后,因为join操作之前线程还没有退出,此时释放会出问题。

int pthread_mutex_destroy(pthread_mutex_t *mutex);

死锁

死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用不会释放的资源而处于一种永久等待状态。 即因为对一些无法加锁的锁进行加锁,而没有导致程序卡死的现象。

死锁产生的必要条件

  1. 互斥条件 :(我操作时别人不能操作) 一个资源每次只能被一个执行流使用。

  2. 不可剥夺条件 :(我加的锁别人不能解锁(释放))

一个执行流已获得的资源,在末使用完之前,不能强行剥夺。

  1. 请求与保持条件 :(拿着手中的锁,请求其它的锁。其他的锁请求不到,手中的锁也不放) 一个执行流因请求资源而阻塞时,对已获得的资源保持不释放状态。

  2. 环路等待(循环等待)条件:

若干执行流之间形成一种头尾相接的循环等待资源的关系。

产生场景:加锁 / 解锁顺序不同,等等。

如何预防死锁? 破坏必要条件!

如何避免死锁?死锁检测算法银行家算法等。

死锁如何处理?非阻塞加锁限时阻塞加锁定义锁序等。


同步

互斥锁、条件变量、生产者与消费者模型、POSIX标准信号量、读写锁(读写者模型)等

同步即临界资源访问的合理性,资源生产出来才能使用,没有资源则等待(死等)资源,生产资源后唤醒等待(唤醒与等待)。

条件变量

  1. 定义条件变量 pthread_cond_t
  2. 初始化条件变量 pthread_cond_init()
  3. 等待 pthread_cond_wait()
  4. 唤醒 pthread_cond_signal()
  5. 销毁条件变量 pthread_cond_destroy()

条件变量的本质也是一个结构体,其中存在等待队列等等数据结构。


初始化条件变量

int pthread_cond_init(pthread_cond_t *cond,pthread_condattr_t *attr);
  • cond:条件变量。
  • attr:条件变量属性,通常置NULL

等待

  1. 在条件变量上等待
pthread_cond_wait(pthread_cond_t *cond,pthread_mutex_t *mutex);
  • cond:条件变量。
  • mutex:互斥锁变量。
  1. 限时等待:(限时进行等待,超时后则返回)
int pthread_cond_timedwait(pthread_cond_t *cond,
	pthread_mutex_t *mutex,
	struct timespec *abstime
);
  • abstime:限时等待时长。 (限时进行等待,超时后则返回)

唤醒

  1. 唤醒至少一个等待的人
int pthread_cond_signal(pthread_cond_t *cond);
  1. 广播唤醒:(唤醒所有等待的人)
int pthread_cond_broadcast(pthread_cond_t *cond);

销毁条件变量

int pthread_cond_destroy(pthread_cond_t *cond);

案例:吃面模型,前提是有人做面,吃面做面的单位都是一碗。

  • 如果没有现成的面,就要等待厨师做出来,厨师做出来后唤醒顾客。(等待与唤醒)
  • 厨师不会做太多的面,只会提前做一碗面,如果已经有面做好,但没人吃,就不会再做(即等待)。
  • 顾客吃完面要求再来一碗,唤醒厨师的等待(即唤醒)。
  • Q:条件变量为什么要搭配互斥锁使用?
  • A1:因为条件变量只提供==等待与唤醒==的功能,具体什么时候等待需要用户来进行判断,这个条件的判断,通常涉及临界资源的操作(其他线程要通过修改条件,来促使条件满足),而这个临界资源的的操作应该受保护,因此搭配互斥锁一起使用。
  • A2:条件变量本身不具有条件判断的功能,只提供等待与唤醒功能, 所以使用一个外部条件进行判断。判断是否满足条件,从而决定是否进入休眠。这个条件在多个线程中都要进行判断,对这个不满足的条件进行休眠等待,其他线程会促使条件满足,意味着其他线程会对这个条件进行修改,故这个条件也是一个临界资源,所以它应该受保护(加锁后操作)

因为竞态条件的原因,所以pthread_cond_wait(&cond,&mutex)操作实现了原子操作:

  1. 解锁
  2. 休眠
  3. 被唤醒后加锁
  • 多个吃面人 与 厨师:
  1. 情况一: 因为促使条件满足后,pthread_cond_signal()唤醒至少一个等待线程,导致因为条件的判断是一个if语句而造成一碗面多次进食的情况(第一个吃面的人加锁吃完面后解锁,第二个被唤醒的吃面者等待在锁上,刚好拿到锁,继续后面逻辑(吃面)),因为条件的判断需要使用while来循环判断。

  2. 情况二: 促使条件满足后(做面),pthread_cond_wait()唤醒的是所有等待在条件变量上的线程,但有可能被唤醒的这个线程也是一个做面的线程,因为已经有面,条件不满足而陷入等待,导致死等。 【本质原因】:唤醒的时候,唤醒了错误的角色。(因为不同的角色等待在同一个条件变量上)。

因此线程有多少角色,就应该有多少个条件变量。分别等待,分别唤醒