线程(二)线程互斥+线程同步

时间:2022-07-24
本文章向大家介绍线程(二)线程互斥+线程同步,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

Linux线程互斥

线程间互斥相关概念

  • 临界资源:多线程执行流共享的资源叫做临界资源。
  • 临界区:每个线程内部,访问临界资源的代码,叫做临界区
  • 互斥:任何时刻,互斥保证只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用。
  • 原子态:不会被任意调度机制打断的操作,该操作只有两种状态,要么完成,要么未完成。
互斥量mutex
  • 大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。
  • 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。
  • 多个线程并发的操作共享变量,会带来一些问题。
// 操作共享变量会有问题的售票系统代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
 
int ticket = 100;
 
void *route(void *arg)
{
    char *id = (char*)arg;
    while ( 1 ) {
        if ( ticket > 0 ) {
            usleep(1000);
            printf("%s sells ticket:%dn", id, ticket);
            ticket--;
        } else {
            break;
        }
    }
}
 
int main( void )
{
    pthread_t t1, t2, t3, t4;
    pthread_create(&t1, NULL, route, "thread 1");
    pthread_create(&t2, NULL, route, "thread 2");
    pthread_create(&t3, NULL, route, "thread 3");
    pthread_create(&t4, NULL, route, "thread 4");
 
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    pthread_join(t3, NULL);
    pthread_join(t4, NULL);
}
 
执行结果:
    thread 4 sells ticket:100
    ...
    thread 4 sells ticket:1
    thread 2 sells ticket:0
    thread 1 sells ticket:-1
    thread 3 sells ticket:-2
1 为什么可能无法获得争取的结果?
  • if 语句判断条件为真以后,代码可以并发的切换到其他线程
  • usleep 这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段
  • (- -ticket) 操作本身就不是一个原子操作
2 — — 操作并不是原子操作,而是对应三条操作指令:

load :将共享变量ticket从内存加载到寄存器中 update : 更新寄存器里面的值,执行-1操作 store :将新值,从寄存器写回共享变量ticket的内存地址

3 如何解决上述问题?
  • 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
  • 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
  • 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量

互斥量的接口

初始化互斥量

初始化互斥量有两种方法:

  • 方法1,静态分配
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
  • 方法2,动态分配
int pthread_mutex_init(pthread_mutex_t *restrict mutex, 
		const pthread_mutexattr_t *restrict attr);
   
   参数:
        mutex:要初始化的互斥量
        attr:NULL
销毁互斥量

销毁互斥量需要注意:

  • 使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁
  • 不要销毁一个已经加锁的互斥量
  • 已经销毁的互斥量,要确保后面不会有线程再尝试加锁
int pthread_mutex_destroy(pthread_mutex_t *mutex);
互斥量加锁与解锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
返回值:成功返回0,失败返回错误号
调用 pthread_ lock 时,可能会遇到以下情况:
  • 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
  • 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。

根据以上知识的理解,对前面抢票问题进行优化:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include <sched.h>
 
int ticket = 100;
pthread_mutex_t mutex;
 
void *route(void *arg)
{
    char *id = (char*)arg;
    while ( 1 ) {
        pthread_mutex_lock(&mutex);
        if ( ticket > 0 ) {
            usleep(1000);
            printf("%s sells ticket:%dn", id, ticket);
            ticket--;
            pthread_mutex_unlock(&mutex);
            // sched_yield(); 放弃CPU
        } else {
            pthread_mutex_unlock(&mutex);
            break;
        }
    }
}
 
int main( void )
{
    pthread_t t1, t2, t3, t4;
 
    pthread_mutex_init(&mutex, NULL);
 
    pthread_create(&t1, NULL, route, "thread 1");
    pthread_create(&t2, NULL, route, "thread 2");
    pthread_create(&t3, NULL, route, "thread 3");
    pthread_create(&t4, NULL, route, "thread 4");
 
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    pthread_join(t3, NULL);
    pthread_join(t4, NULL);
    pthread_mutex_destroy(&mutex);
}

可重入VS线程安全

概念:

  • **线程安全:**多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
  • **可重入:**同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。

常见的线程不安全的情况:

  • 不保护共享变量的函数
  • 函数状态随着被调用,状态发生变化的函数
  • 返回指向静态变量指针的函数
  • 调用线程不安全函数的函数

常见线程安全的情况

  • 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
  • 类或者接口对于线程来说都是原子操作
  • 多个线程之间的切换不会导致该接口的执行结果存在二义性

常见不可重入的情况

  • 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
  • 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
  • 可重入函数体内使用了静态的数据结构

常见可重入的情况

  • 不使用全局变量或静态变量
  • 不使用用malloc或者new开辟出的空间
  • 不调用不可重入函数
  • 不返回静态或全局数据,所有数据都有函数的调用者提供
  • 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据

可重入与线程安全联系

  • 函数是可重入的,那就是线程安全的
  • 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
  • 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的

可重入与线程安全区别

  • 可重入函数是线程安全函数的一种
  • 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
  • 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的

常见锁概念

死锁的概念

死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。

形成死锁的四个必要条件
  • 互斥条件:一个资源每次只能被一个执行流使用
  • 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
  • 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
  • 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
避免死锁的方法
  • 破坏死锁的四个必要条件
  • 加锁顺序一致
  • 避免锁未释放的场景
  • 资源一次性分配

Linux线程同步

条件变量

  • 当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了
  • 例如一个线程访问队列时,发现队列为空,它只能等待,只到其它线程将一个节点添加到队列中。这种情况就需要用到条件变量。

同步概念与竟态条件

  • 同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步
  • 竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。在线程场景下,这种问题也不难理解

条件变量函数

初始化
int pthread_cond_init(pthread_cond_t *restrict cond,
	const pthread_condattr_t *restrict attr);
参数:
    cond:要初始化的条件变量
    attr:NULL
销毁
int pthread_cond_destroy(pthread_cond_t *cond)
等待条件满足
int pthread_cond_wait(pthread_cond_t *restrict cond,
					pthread_mutex_t *restrict mutex);
参数:
        cond:要在这个条件变量上等待
        mutex:互斥量
唤醒等待
	 int pthread_cond_broadcast(pthread_cond_t *cond);//唤醒所有等待
  	 int pthread_cond_signal(pthread_cond_t *cond);//至少唤醒一个等待
模拟运用线程同步:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
 
pthread_cond_t cond;
pthread_mutex_t mutex;
void *r1( void *arg ) 
{
    while ( 1 ){
        pthread_cond_wait(&cond, &mutex);
        printf("活动n");
    }
}
 
void *r2(void *arg )
{
    while ( 1 ) {
        pthread_cond_signal(&cond);
        sleep(1);
    }
}
 
int main( void )
{
    pthread_t t1, t2;
 
    pthread_cond_init(&cond, NULL);
    pthread_mutex_init(&mutex, NULL);
 
    pthread_create(&t1, NULL, r1, NULL);
    pthread_create(&t2, NULL, r2, NULL);
 
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
 
    pthread_mutex_destroy(&mutex);
    pthread_cond_destroy(&cond);
}
 
运行结果:
[root@localhost linux]# ./a.out
    活动
    活动
    活动

为什么pthread_ cond_ wait 需要互斥量?

  • 条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所以必须要有一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足,并且友好的通知等待在条件变量上的线程。
  • 条件不会无缘无故的突然变得满足了,必然会牵扯到共享数据的变化。所以一定要用互斥锁来保护。没有互斥锁就无法安全的获取和修改共享数据。
  • 由于解锁和等待不是原子操作。调用解锁之后,pthread_ cond_ wait之前,如果已经有其他线程获取到互斥量,摒弃条件满足,发送了信号,那么pthread_ cond_ wait将错过这个信号,可能会导致线程永远阻塞在这个 pthread_ cond_ wait 。所以解锁和等待必须是一个原子操作。
  • int pthread_cond_wait(pthread_cond_ t *cond,pthread_mutex_ t * mutex); 进入该函数后,会去看条件量等于0不?等于,就把互斥量变成1,直到cond_ wait返回,把条件量改成1,把互斥量恢复成原样。

条件变量使用规范

  • 等待条件代码
pthread_mutex_lock(&mutex);
    while (条件为假)
        pthread_cond_wait(cond, mutex);
    修改条件
    pthread_mutex_unlock(&mutex);
  • 给条件发送信号代码
	pthread_mutex_lock(&mutex);
    设置条件为真
    pthread_cond_signal(cond);
    pthread_mutex_unlock(&mutex);