你好,我是拉依达。这是我的Linux应用开发八股文详细解析系列。本系列最开始是我在csdn上更新的文章全文总字数超3w字,现重新对内容进行整理,希望可以帮助到更多学习嵌入式的同学。【下面是拉依达推荐学习相关专栏:】 一、Linux驱动学习专栏:拉依达的Linux驱动八股文 - 牛客网 二、Linux应用学习专栏:拉依达的Linux应用八股文 - 牛客网 【我的嵌入式学习和校招经验】拉依达的嵌入式学习和秋招经验-CSDN博客 嵌入式学习规划/就业经验指导,可私信咨询———————————————————————————————————————————————————五、线程同步所谓的同步并不是多个线程同时对内存进行访问,而是按照先后顺序依次进行的。5.1 线程同步方式对于多个线程访问共享资源出现数据混乱的问题,需要进行线程同步。常用的线程同步方式有四种:互斥锁、读写锁、条件变量、信号量。所谓的共享资源就是多个线程共同访问的变量,这些变量通常为全局数据区变量或者堆区变量,这些变量对应的共享资源也被称之为临界资源。找到临界资源之后,再找和临界资源相关的上下文代码,这样就得到了一个代码块,这个代码块可以称之为临界区。确定好临界区(临界区越小越好)之后,就可以进行线程同步了,线程同步的大致处理思路是这样的:在临界区代码的上边,添加加锁函数,对临界区加锁。哪个线程调用这句代码,就会把这把锁锁上,其他线程就只能阻塞在锁上了。在临界区代码的下边,添加解锁函数,对临界区解锁。出临界区的线程会将锁定的那把锁打开,其他抢到锁的线程就可以进入到临界区了。通过锁机制能保证临界区代码最多只能同时有一个线程访问,这样并行访问就变为串行访问了。5.2 互斥锁互斥锁是线程同步最常用的一种方式,通过互斥锁可以锁定一个代码块,被锁定的这个代码块,所有的线程只能顺序执行 (不能并行处理),这样多线程访问共享资源数据混乱的问题就可以被解决了,需要付出的代价就是执行效率的降低,因为默认临界区多个线程是可以并行处理的,现在只能串行处理。在 Linux 中互斥锁的类型为 pthread_mutex_t,创建一个这种类型的变量就得到了一把互斥锁:pthread_mutex_t mutex;在创建的锁对象中保存了当前这把锁的状态信息:锁定还是打开,如果是锁定状态还记录了给这把锁加锁的线程信息(线程 ID)。一个互斥锁变量只能被一个线程锁定,被锁定之后其他线程再对互斥锁变量加锁就会被阻塞,直到这把互斥锁被解锁,被阻塞的线程才能被解除阻塞。一般情况下,每一个共享资源对应一个把互斥锁,锁的个数和线程的个数无关。// 初始化互斥锁// restrict: 是一个关键字, 用来修饰指针, 只有这个关键字修饰的指针可以访问指向的内存地址, 其他指针是不行的int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);// 释放互斥锁资源 int pthread_mutex_destroy(pthread_mutex_t *mutex);参数:mutex: 互斥锁变量的地址attr: 互斥锁的属性,一般使用默认属性即可,这个参数指定为 NULL加锁函数(加锁失败阻塞):// 修改互斥锁的状态, 将其设定为锁定状态, 这个状态被写入到参数 mutex 中int pthread_mutex_lock(pthread_mutex_t *mutex);这个函数被调用,首先会判断参数 mutex 互斥锁中的状态是不是锁定状态:没有被锁定,是打开的,这个线程可以加锁成功,这个这个锁中会记录是哪个线程加锁成功了如果被锁定了,其他线程加锁就失败了,这些线程都会阻塞在这把锁上当这把锁被解开之后,这些阻塞在锁上的线程就解除阻塞了,并且这些线程是通过竞争的方式对这把锁加锁,没抢到锁的线程继续阻塞尝试加锁函数(加锁失败非阻塞):// 尝试加锁int pthread_mutex_trylock(pthread_mutex_t *mutex);调用这个函数对互斥锁变量加锁还是有两种情况:如果这把锁没有被锁定是打开的,线程加锁成功如果锁变量被锁住了,调用这个函数加锁的线程,不会被阻塞,加锁失败直接返回错误号解锁函数:// 对互斥锁解锁int pthread_mutex_unlock(pthread_mutex_t *mutex);5.3 死锁当多个线程访问共享资源,需要加锁,如果锁使用不当,就会造成死锁这种现象。如果线程死锁造成的后果是:所有的线程都被阻塞,并且线程的阻塞是无法解开的(因为可以解锁的线程也被阻塞了)。死锁的场景:加锁之后忘记解锁重复加锁,造成死锁在程序中有多个共享资源,因此有很多把锁,随意加锁,导致相互被阻塞场景描述:有两个共享资源:X, Y,X对应锁A, Y对应锁B- 线程A访问资源X, 加锁A- 线程B访问资源Y, 加锁B线程A要访问资源Y, 线程B要访问资源X,因为资源X和Y已经被对应的锁锁住了,因此这个两个线程被阻塞- 线程A被锁B阻塞了, 无法打开A锁- 线程B被锁A阻塞了, 无法打开B锁避免死锁避免多次锁定,多检查对共享资源访问完毕之后,一定要解锁,或者在加锁的使用 trylock如果程序中有多把锁,可以控制对锁的访问顺序 (顺序访问共享资源,但在有些情况下是做不到的),另外也可以在对其他互斥锁做加锁操作之前,先释放当前线程拥有的互斥锁。项目程序中可以引入一些专门用于死锁检测的模块5.4 读写锁在做读操作的时候可以提高程序的执行效率,如果所有的线程都是做读操作, 那么读是并行的读写锁是一把锁,锁的类型为 pthread_rwlock_t,有了类型之后就可以创建一把互斥锁了pthread_rwlock_t rwlock;之所以称其为读写锁,是因为这把锁既可以锁定读操作,也可以锁定写操作。为了方便理解,可以大致认为在这把锁中记录了这些信息:锁的状态:锁定 / 打开锁定的是什么操作:读操作 / 写操作,使用读写锁锁定了读操作,需要先解锁才能去锁定写操作,反之亦然。哪个线程将这把锁锁上了读写锁的使用方式也互斥锁的使用方式是完全相同的:找共享资源,确定临界区,在临界区的开始位置加锁(读锁 / 写锁),临界区的结束位置解锁。因为通过一把读写锁可以锁定读或者写操作,下面介绍一下关于读写锁的特点:使用读写锁的读锁锁定了临界区,线程对临界区的访问是并行的,读锁是共享的。使用读写锁的写锁锁定了临界区,线程对临界区的访问是串行的,写锁是独占的。使用读写锁分别对两个临界区加了读锁和写锁,两个线程要同时访问者两个临界区,访问写锁临界区的线程继续运行,访问读锁临界区的线程阻塞,因为写锁比读锁的优先级高。如果说程序中所有的线程都对共享资源做写操作,使用读写锁没有优势,和互斥锁是一样的,如果说程序中所有的线程都对共享资源有写也有读操作,并且对共享资源读的操作越多,读写锁更有优势。读写锁的操作#include <pthread.h>pthread_rwlock_t rwlock;// 初始化读写锁int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock, const pthread_rwlockattr_t *restrict attr);// 释放读写锁占用的系统资源int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);参数:rwlock: 读写锁的地址,传出参数attr: 读写锁属性,一般使用默认属性,指定为 NULL// 在程序中对读写锁加读锁, 锁定的是读操作int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);调用这个函数,如果读写锁是打开的,那么加锁成功;如果读写锁已经锁定了读操作,调用这个函数依然可以加锁成功,因为读锁是共享的;如果读写锁已经锁定了写操作,调用这个函数的线程会被阻塞。// 这个函数可以有效的避免死锁// 如果加读锁失败, 不会阻塞当前线程, 直接返回错误号int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock);调用这个函数,如果读写锁是打开的,那么加锁成功;如果读写锁已经锁定了读操作,调用这个函数依然可以加锁成功,因为读锁是共享的;如果读写锁已经锁定了写操作,调用这个函数加锁失败,对应的线程不会被阻塞,可以在程序中对函数返回值进行判断,添加加锁失败之后的处理动作。// 在程序中对读写锁加写锁, 锁定的是写操作int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);调用这个函数,如果读写锁是打开的,那么加锁成功;如果读写锁已经锁定了读操作或者锁定了写操作,调用这个函数的线程会被阻塞。// 这个函数可以有效的避免死锁// 如果加写锁失败, 不会阻塞当前线程, 直接返回错误号int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock);调用这个函数,如果读写锁是打开的,那么加锁成功;如果读写锁已经锁定了读操作或者锁定了写操作,调用这个函数加锁失败,但是线程不会阻塞,可以在程序中对函数返回值进行判断,添加加锁失败之后的处理动作。// 解锁, 不管锁定了读还是写都可用解锁int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);5.5 条件变量条件变量的主要作用不是处理线程同步,而是进行线程的阻塞。如果在多线程程序中只使用条件变量无法实现线程的同步,必须要配合互斥锁来使用。虽然条件变量和互斥锁都能阻塞线程,但是二者的效果是不一样的,二者的区别如下:假设有 A-Z 26 个线程,这 26 个线程共同访问同一把互斥锁,如果线程 A 加锁成功,那么其余 B-Z 线程访问互斥锁都阻塞,所有的线程只能顺序访问临界区条件变量只有在满足指定条件下才会阻塞线程,如果条件不满足,多个线程可以同时进入临界区,同时读写临界资源,这种情况下还是会出现共享资源中数据的混乱。条件变量类型对应的类型为 pthread_cond_t,这样就可以定义一个条件变量类型的变量了:pthread_cond_t cond;//被条件变量阻塞的线程的线程信息会被记录到这个变量中,以便在解除阻塞的时候使用。#include <pthread.h>pthread_cond_t cond;// 初始化int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);// 销毁释放资源 int pthread_cond_destroy(pthread_cond_t *cond);参数:cond: 条件变量的地址attr: 条件变量属性,一般使用默认属性,指定为 NULL// 线程阻塞函数, 哪个线程调用这个函数, 哪个线程就会被阻塞int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);通过函数原型可以看出,该函数在阻塞线程的时候,需要一个互斥锁参数,这个互斥锁主要功能是进行线程同步,让线程顺序进入临界区,避免出现数共享资源的数据混乱。该函数会对这个互斥锁做以下几件事情:在阻塞线程时候,如果线程已经对互斥锁 mutex 上锁,那么会将这把锁打开,这样做是为了避免死锁当线程解除阻塞的时候,函数内部会帮助这个线程再次将这个 mutex 互斥锁锁上,继续向下访问临界区// 表示的时间是从1971.1.1到某个时间点的时间, 总长度使用秒/纳秒表示struct timespec { time_t tv_sec; /* Seconds */ long tv_nsec; /* Nanoseconds [0 .. 999999999] */};// 将线程阻塞一定的时间长度, 时间到达之后, 线程就解除阻塞了int pthread_cond_timedwait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex, const struct timespec *restrict abstime);这个函数的前两个参数和 pthread_cond_wait 函数是一样的,第三个参数表示线程阻塞的时长,但是需要额外注意一点:struct timespec 这个结构体中记录的时间是从1971.1.1到某个时间点的时间,总长度使用秒/纳秒表示。因此赋值方式相对要麻烦一点:time_t mytim = time(NULL); // 1970.1.1 0:0:0 到当前的总秒数struct timespec tmsp;tmsp.tv_nsec = 0;tmsp.tv_sec = time(NULL) + 100; // 线程阻塞100s// 唤醒阻塞在条件变量上的线程, 至少有一个被解除阻塞int pthread_cond_signal(pthread_cond_t *cond);// 唤醒阻塞在条件变量上的线程, 被阻塞的线程全部解除阻塞int pthread_cond_broadcast(pthread_cond_t *cond);调用上面两个函数中的任意一个,都可以换线被 pthread_cond_wait 或者 pthread_cond_timedwait 阻塞的线程,区别就在于 pthread_cond_signal 是唤醒至少一个被阻塞的线程(总个数不定),pthread_cond_broadcast 是唤醒所有被阻塞的线程。5.6 信号量信号量(信号灯)与互斥锁和条件变量的主要不同在于” 灯” 的概念,灯亮则意味着资源可用,灯灭则意味着不可用。信号量主要阻塞线程,不能完全保证线程安全,如果要保证线程安全,需要信号量和互斥锁一起使用。信号的类型为 sem_t 对应的头文件为 <semaphore.h>:#include <semaphore.h>sem_t sem;信号量操作函数原型如下:#include <semaphore.h>// 初始化信号量/信号灯int sem_init(sem_t *sem, int pshared, unsigned int value);// 资源释放, 线程销毁之后调用这个函数即可// 参数 sem 就是 sem_init() 的第一个参数 int sem_destroy(sem_t *sem);参数:sem:信号量变量地址pshared:0:线程同步非 0:进程同步value:初始化当前信号量拥有的资源数(>=0),如果资源数为 0,线程就会被阻塞了。// 参数 sem 就是 sem_init() 的第一个参数 // 函数被调用sem中的资源就会被消耗1个, 资源数-1int sem_wait(sem_t *sem);当线程调用这个函数,并且 sem 中的资源数 >0,线程不会阻塞,线程会占用 sem 中的一个资源,因此资源数 - 1,直到 sem 中的资源数减为 0 时,资源被耗尽,因此线程也就被阻塞了。// 参数 sem 就是 sem_init() 的第一个参数 // 函数被调用sem中的资源就会被消耗1个, 资源数-1int sem_trywait(sem_t *sem);当线程调用这个函数,并且 sem 中的资源数 >0,线程不会阻塞,线程会占用 sem 中的一个资源,因此资源数 - 1,直到 sem 中的资源数减为 0 时,资源被耗尽,但是线程不会被阻塞,直接返回错误号,因此可以在程序中添加判断分支,用于处理获取资源失败之后的情况。// 表示的时间是从1971.1.1到某个时间点的时间, 总长度使用秒/纳秒表示struct timespec { time_t tv_sec; /* Seconds */ long tv_nsec; /* Nanoseconds [0 .. 999999999] */};// 调用该函数线程获取sem中的一个资源,当资源数为0时,线程阻塞,在阻塞abs_timeout对应的时长之后,解除阻塞。// abs_timeout: 阻塞的时间长度, 单位是s, 是从1970.1.1开始计算的int sem_timedwait(sem_t *sem, const struct timespec *abs_timeout);该函数的参数 abs_timeout 和 pthread_cond_timedwait 的最后一个参数是一样的,使用方法不再过多赘述。当线程调用这个函数,并且 sem 中的资源数 >0,线程不会阻塞,线程会占用 sem 中的一个资源,因此资源数 - 1,直到 sem 中的资源数减为 0 时,资源被耗尽,线程被阻塞,当阻塞指定的时长之后,线程解除阻塞。// 调用该函数给sem中的资源数+1int sem_post(sem_t *sem);调用该函数会将 sem 中的资源数 +1,如果有线程在调用 sem_wait、sem_trywait、sem_timedwait 时因为 sem 中的资源数为 0 被阻塞了,这时这些线程会解除阻塞,获取到资源之后继续向下运行。// 查看信号量 sem 中的整形数的当前值, 这个值会被写入到sval指针对应的内存中// sval是一个传出参数int sem_getvalue(sem_t *sem, int *sval);通过这个函数可以查看 sem 中现在拥有的资源个数,通过第二个参数 sval 将数据传出,也就是说第二个参数的作用和返回值是一样的。