文章目录
- 一、什么是线程
- 1.线程是怎样描述的
- 2.线程与进程的区别
- 3.线程的优缺点
- 4.理解Linux的轻量级进程
- 二、Linux线程控制
- 1.线程创建:pthread_create()
- 2.线程终止:pthread_exit()
- 3.线程等待:pthread_join()
- 4.分离线程:pthread_detach()
- 三、Linux线程互斥
- 1.互斥量
- 2.线程安全与重入函数
- 四、Linux常见的锁
- 1.自旋锁
- 2.互斥锁
- 3.读写锁
- 4.乐观锁与悲观锁
- 5.RCU锁
- 五、Linux线程同步
- 1.线程同步的概念
- 2.互斥与同步的区别
- 3.条件变量
- a).条件变量是什么
- b).具体用法
- 初始化条件变量
- 阻塞当前线程,等待条件成立
- 解除线程的阻塞状态
- 销毁条件变量
- c).深入探讨
- c.1.为什么 pthread_cond_wait 需要互斥量?
- c.2.传入前为什么要加锁?
- c.3.条件不满足时,为何要解锁?(该动作由pthread_cond_wait自动完成)
- c.4.线程被唤醒之后,为何需要加锁呢?(该动作由pthread_cond_wait自动完成)
- c.5.要使用 while 而不是 if,避免虚假唤醒!
- c.6.退出临界区时为何要解锁(这个动作需要我们完成)?
一、什么是线程
线程就是 Light weight process ,LWP,轻量级进程,在Linux环境下它仍然是进程,一个进程内部可以有多个线程,默认情况下一个进程内部有一个线程。
1.线程是怎样描述的
在类Uinx系统中,早期是没有线程概念的,直到80年代才引入,借助进程机制实现出了线程的概念,因此在类Uinx系统中,线程和进程密切相关。
- 首先线程是轻量级进程LWP,线程也有PCB,创建线程所使用的底层函数和进程一样,都是clone。
- 从内核的角度来看,进程和线程是一样的,都有自己不同的PCB。
- 进程中可以包含很多线程,并且进程至少包含一个线程。
- 线程可以看作是寄存器和栈的集合。
- 在Linux下,线程是CPU调度的基本单位,进程是资源分配的基本单位。
- 可以通过 ps -Lf pid 来查看指定线程的lwp号。
线程实际上也是一个task_struct,工作线程拷贝主线程的task_struct,然后共用主线程的mm_struct。线程ID是在用task_struct中pid描述的,而task_struct中tgid是线程组ID,表示线程属于该线程组,对于主线程而言,其pid和tgid是相同的,我们一般看到的进程ID就是tgid。
获取线程ID和主线程ID的值:
用户态 | 系统调用 | mm_struct对应的结构 |
---|---|---|
线程ID | pid_t gettid(void) | pid_t pid |
进程(组)ID | pid_t getpid(void) | pid_t tgid |
所以线程ID,进程ID的关系如下:
2.线程与进程的区别
线程与进程的共享资源:
- 文件描述符
- 每种信号的处理方式
- 当前工作目录
- 用户ID和组ID
- 内存地址空间,text段、data段、bss段、heap段、共享库。
进程的独享资源:
- 线程ID
- 处理器现场和栈指针(内核栈)
- 独立的栈空间(用户空间栈)
- errno变量,每个线程都有自己的errno,所以不能再像进程那样使用perror来打印,应该使用sterror函数打印。
- 信号屏蔽字,多线程编程中,应尽量避免使用信号。
- 调度优先级
这里有一个问题,为什么线程的错误检查要用strerror而不用perror:
在库函数中有个errno的全局变量,每个errno的值对应错误的类型。
- 当我们调用某些函数出错时,该函数就设置了errno的值,perror就将errno值对应的错误类型打印出来(这也是perror要紧跟着函数调用的原因);
- 而在另外一些函数中,函数出错并不设置errno的值,而是通过返回错误类型对应的值,来得到错误的类型,在这种情况下,我们就要使用strerror(pthreads线程系列函数就是这类);
- pthreads函数出错时不会设置全局变量errno(而大部分其他POSIX函数会这样做)。而是将错误代码通过返回值返回;
- pthreads同样也提供了线程内的errno变量,以支持其它使用errno的代码。对于pthreads函数的错误,建议通过返回值判定,因为读取返回值要比读取线程内的errno变量的开销更小;
3.线程的优缺点
线程的优点:
- 创建一个新线程的代价要比创建一个新进程小得多
- 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
- 线程占用的资源要比进程少很多
- 能充分利用多处理器的可并行数量
- 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
- I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
线程的缺点:
- 性能损失
一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型
线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的
同步和调度开销,而可用的资源不变。- 健壮性降低
编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了
不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。- 缺乏访问控制
进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。- 编程难度提高
编写与调试一个多线程程序比单线程程序困难得多
线程异常:
- 单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃
- 线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出
4.理解Linux的轻量级进程
- 我们可以将进程的资源划分给不同的线程,让线程来执行某些代码块,而线程就是进程内部的一个执行流。那么此时我们就可以通过地址空间+页表的方式将进程的资源划分给每一个线程,那么线程的执行粒度一定比之前的进程更细。
- Linux中并没有专门为线程创建真正的数据结构来管理,而是直接复用进程的PCB当作线程的描述结构体,用PCB来当作Linux系统内部的“线程”。这么做的好处是什么呢?如果要创建真正的线程结构体,那就需要对其进行维护,需要和进程构建好关系,每个线程还需要和地址空间进行关联,CPU调度进程和调度线程还不一样,OS要对内核中大量的进程和线程做管理,这样维护的成本太高了!不利于系统的稳定性和健壮性,所以直接复用PCB是一个很好的选择,维护起来的成本很低,。所以这也是Linux系统即稳定又高效,成为各大互联网公司服务器系统选择的原因。
- 所以Linux内核是怎么设计线程的?Linux用进程的PCB来模拟线程,是完全属于自己实现的一套方案!站在CPU的角度来看,每一个PCB,都可以称之为轻量级进程,因为它只需要PCB即可,而进程承担分配的资源更多,量级更重!Linux线程是CPU调度的基本单位,进程是承担分配系统资源的基本实体!进程用来整体向OS申请资源,线程负责向进程伸手要资源。如果线程向OS申请资源,实质上也是进程在向OS要资源,因为线程在进程内部运行,是进程内部的一部分!用PCB模拟线程的好处是维护成本大大降低,系统变得更加可靠、高效、稳定。windows是给老百姓用的,可用性必须要高。Linux是给程序用的,必须要可靠稳定高效。所以需求的不同,产生了不同实现方案的操作系统。
- 我们说Linux中没有线程只有轻量级进程,怎么证明呢?因为Linux内核没有为线程设计数据结构,而是复用了进程的PCB。所以Linux无法直接提供创建线程的系统调用接口,只能提供创建轻量级进程的接口。轻量级进程是建立在内核之上并由内核支持的用户线程,它是内核线程的高度抽象,每一个轻量级进程都与一个特定的内核线程关联。
- Linux为了让用户能够得到他想要的线程,只能通过原生线程库来给用户他想要的,所以在用户和内核之间有一个软件层,这个软件层负责给程序员创建出想要的线程。除这个原生线程库会创建出线程结构体外,同时Linux内核中会通过一个叫clone的系统调用来对应的创建出一个轻量级进程,所以我们称这个库是用户级线程库,因为Linux是没有真正意义上的线程的,无法给用户创建线程,只能创建对应的PCB,也就是轻量级进程!而且如果编译时不带-lpthread 选项,可以看到g++报错pthread_create()函数未定义,其实就是因为链接器链接不上具体的动态库,此时就可以看出来Linux内核中并没有真正意义的线程,他无法提供创建线程的接口,而只能通过第三方库libphread.so或libpthread.a来提供创建线程的接口。
二、Linux线程控制
首先我们先来介绍一下POSIX线程库的使用:
- 与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以“pthread_”打头的
- 要使用这些函数库,要通过引入头文<pthread.h>
- 链接这些线程函数库时要使用编译器命令的“-lpthread”选项
1.线程创建:pthread_create()
功能:创建一个新的线程
原型
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void* (*start_routine)(void*), void *arg);
参数
thread:返回线程ID
attr:设置线程的属性,attr为NULL表示使用默认属性
start_routine:是个函数地址,线程启动后要执行的函数
arg:传给线程启动函数的参数
返回值:成功返回0;失败返回错误码
对于返回值的理解:
返回值 | 描述 |
---|---|
EAGAIN | 系统资源不够,或者创建线程的个数超过系统对一个进程中线程总数的限制 |
EINVAL | 第二个参数attr值不合法 |
EPERM | 没有合适的权限来设置调度策略或参数 |
传入参数arg的选择:
传入参数 | 分析 | 是否可行 |
---|---|---|
临时变量 | 临时变量的生命周期,临时变量的值会改变,传递临时变量有可能导致越界问题 | 不可行 |
结构体对象 | 和临时变量相同 | 不可行 |
结构体指针 | 释放时,在线程不会使用该指针之后 | 可行 |
this指针 | 可行 |
对于第一个参数(pthread_t)的理解:
- pthread_ create函数会产生一个线程ID,存放在第一个参数指向的地址中。该线程ID和前面说的线程ID不是一回事。
- 前面讲的线程ID属于进程调度的范畴。因为线程是轻量级进程,是操作系统调度器的最小单位,所以需要一个数值来唯一表示该线程。
- pthread_ create函数第一个参数指向一个虚拟内存单元,该内存单元的地址即为新创建线程的线程ID,
属于NPTL线程库的范畴。线程库的后续操作,就是根据该线程ID来操作线程的。- 线程库NPTL提供了函数,可以获得线程自身的ID:
pthread_t pthread_self(void);
pthread_t 到底是什么类型呢?取决于实现。对于Linux目前实现的NPTL实现而言,pthread_t类型的线程ID,本质就是一个进程地址空间上的一个地址。
我们用下面这段代码来看看pthread_t 是什么:
#include <iostream>
#include <pthread.h>
#include <assert.h>
#include <unistd.h>
#include <cstdio>void *thread_routine(void *agrs)
{const char *name = (const char *)agrs;while (true){printf("%d----->%p\n",pthread_self(),pthread_self());sleep(1);}
}int main()
{pthread_t tid;int n = pthread_create(&tid, nullptr, thread_routine, (void *)"thread one");printf("%p\n",tid);assert(n == 0);(void)n;while (true){printf("我是主进程:%p\n",tid);sleep(1);}return 0;
}
得到的结果如下,这也证实了我们上面的说法:
2.线程终止:pthread_exit()
我们之前提到过,如果一个线程异常终止,那么会影响整个进程,导致整个进程中的进程终止,如果需要只终止某个线程而不终止整个进程,可以有三种方法:
- 从线程函数return。这种方法对主线程不适用,从main函数return相当于调用exit。
- 线程可以调用pthread_ exit终止自己,并释放其占用资源。
- 一个线程可以调用pthread_ cancel终止同一进程中的另一个线程。
功能:线程终止
原型
void pthread_exit(void *value_ptr);
参数
value_ptr:value_ptr不要指向一个局部变量。
返回值:无返回值,跟进程一样,线程结束的时候无法返回到它的调用者(自身)
需要注意,pthread_exit或者return返回的指针所指向的内存单元必须是全局的或者是用malloc分配的,不能在线程函数的栈上分配,因为当其它线程得到这个返回指针时线程函数已经退出了。
功能:取消一个执行中的线程
原型
int pthread_cancel(pthread_t thread);
参数
thread:线程ID
返回值:成功返回0;失败返回错误码
3.线程等待:pthread_join()
线程等待和进程等待的不同:
- 进程之间的等待只能是父进程等待子进程, 而线程则不然。线程组内的成员是对等的关系, 只要是在一个线程组内, 就可以对另外一个线程执行连接(join) 操作。
- 进程可以等待任一子进程的退出 , 但是线程的连接操作没有类似的接口, 即不能连接线程组内的任一线程, 必须明确指明要连接的线程的线程ID。
为什么要有线程等待?pthread_join()防止目标线程还没办完事就被释放。我们写线程等待是为了让调用线程等待目标线程结束,然后获取它的返回值或者退出状态。如果不等待,那么目标线程可能还没有结束就被释放了,导致资源泄露或者不一致的结果。
而所指的资源指的又是什么呢?
- 已经退出的线程, 其空间没有被释放, 仍然在进程的地址空间之内。
- 新创建的线程, 没有复用刚才退出的线程的地址空间。
如果不执行连接操作, 线程的资源就不能被释放, 也不能被复用, 这就造成了资源的泄漏。
功能:等待线程结束
原型
int pthread_join(pthread_t thread, void **value_ptr);
参数
thread:线程ID
value_ptr:它指向一个指针,后者指向线程的返回值
返回值:成功返回0;失败返回错误码
调用该函数的线程将挂起等待,直到id为thread的线程终止。thread线程以不同的方法终止,通过pthread_join得到的终止状态是不同的,总结如下:
- 如果thread线程通过return返回,value_ ptr所指向的单元里存放的是thread线程函数的返回值。
- 如果thread线程被别的线程调用pthread_ cancel异常终掉,value_ ptr所指向的单元里存放的是常数
PTHREAD_ CANCELED。- 如果线程是自己调用pthread_exit终止的,value_ptr所指向的单元存放的是传给pthread_exit的参数。
- 如果对thread线程的终止状态不感兴趣,可以传NULL给value_ ptr参数。
4.分离线程:pthread_detach()
- 默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成系统泄漏。
- 如果不关心线程的返回值,join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源。
int pthread_detach(pthread_t thread);
- 可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离:
- joinable和分离是冲突的,一个线程不能既是joinable又是分离的。
三、Linux线程互斥
相关概念:
- 临界资源:多线程执行流共享的资源就叫做临界资源
- 临界区:每个线程内部,访问临界资源的代码,就叫做临界区
- 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,对临界资源起保护作用
例如我们用一个多线程程序来进行抢票:
#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:%d\n", 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);
}
得到的结果如下,连 -2 都出来了,这显然不符合常理!
那么这是什么原因呢?
- 假设现在有两个线程A和B,单核CPU的情况下,此时有一个int类型的全局变量为100,A和B的入口函数都要对这个全局变量进行–操作。
- 线程A先拿到CPU资源后,对全局变量进行–操作并不是原子性操作,也就是意味着,A在执行–的过程中有可能会被打断。假设A刚刚将全局变量的值读到寄存器当中,就被切换出去了,此时程序计数器保存了下一条执行的指令,上下文信息保存寄存器中的值,这两个东西是用来线程A再次拿到CPU资源后,恢复现场使用的。
- 此时,线程B拿到了CPU资源,对全局变量进行了–操作,并且将100减为了99,回写到了内存中。
A再次拥有了CPU资源后,恢复现场,继续往下执行,从寄存器中读到的值仍为100,减完之后为99,回写到内存中为99。- 上述例子中,线程A和B都对全局变量进行了–操作,全局变量的值应该变为98,但程序现在实际的结果为99,所以这就导致了线程不安全。
解决方案只需做到下述三点即可:
- 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
- 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
- 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
则本质上,我们需要对该临界区加一把锁:
1.互斥量
互斥量的初始化
//静态分配
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER//动态分配
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
参数 | 说明 |
---|---|
mutex | 要初始化的互斥量 |
attr | 设置互斥量的属性,默认为NULL |
互斥量的销毁
int pthread_mutex_destroy(pthread_mutex_t *mutex);
注意:
1、使用PTHREAD_MUTEX_INITIALIZER初始化的互斥量无须销毁。
2、不要销毁一个已加锁的互斥量, 或者是真正配合条件变量使用的互斥量。
3、已经销毁的互斥量, 要确保后面不会有线程再尝试加锁。
4、当互斥量处于已加锁的状态, 或者正在和条件变量配合使用, 调用pthread_mutex_destroy函数会返回EBUSY错误码。
互斥量的加锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
1、该接口是阻塞加锁接口。
2、mutex为传入互斥锁变量的地址
3、如果mutex当中的计数器为1,pthread_mutex_lock接口就返回了,表示加锁成功,同时计数器当中的值会被更改为0.
4、如果mutex当中的计数器为0,pthread_mutex_lock接口就阻塞了,pthread_mutex_lock接口没有返回了,阻塞在函数内部,直到加锁成功
int pthread_mutex_trylock(pthread_mutex_t *mutex);
1、该接口为非阻塞接口
2、mutex中计数器为1时,加锁成功,计数器置为0,然后返回
3、mutex中计数器为0时,加锁失败,但也会返回,此时加锁是失败状态,一定不要去访问临界资源
4、非阻塞接口一般都需要搭配循环来使用。
int pthread_mutex_timedlock(pthread_mutex_t *restrict mutex, const struct timespec *restrict abs_timeout);
1、带有超时时间的加锁接口
2、不能直接获取互斥锁的时候,会等待abs_timeout时间
3、如果在这个时间内加锁成功了,直接返回,不需要再继续等待剩余的时间,并且表示加锁成功
4、如果超出了该时间,也返回了,但是加锁失败了,需要循环加锁
互斥量的解锁
int pthread_mutex_unlock(pthread_mutex_t *mutex);
1、对上述所有的加锁接口,都可使用该函数解锁
2、解锁的时候,会将互斥锁当中计数器的值从0变为1,表示其它线程可以获取互斥量
3. unlock主动解锁函数,同时将阻塞在该锁上的所有线程全部唤醒,至于哪个线程先被唤醒,取决于优先级、调度。默认:先阻塞、先唤醒。
互斥锁的本质:
1、在互斥锁内部有一个计数器,其实就是互斥量,计数器的值只能为0或者为1
2、当线程获取互斥锁的时候,如果计数器当前值为0,表示当前线程不能获取到互斥锁,也就是没有获取到互斥锁,就不要去访问临界资源
3、当前线程获取互斥锁的时候,如果计数器当前值为1,表示当前线程可以获取到互斥锁,也就是意味着可以访问临界资源
为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的 总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。 现在我们把lock和unlock的伪代码改一下:
2.线程安全与重入函数
- 线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
- 重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
常见不可重入的情况:
- 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
- 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
- 可重入函数体内使用了静态的数据结构
常见的线程不安全的情况:
- 不保护共享变量的函数
- 函数状态随着被调用,状态发生变化的函数
- 返回指向静态变量指针的函数
- 调用线程不安全函数的函数
可重入与线程安全联系
- 函数是可重入的,那就是线程安全的
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
- 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
四、Linux常见的锁
在使用锁时需要明确几个问题:
- 锁的所有权问题 谁加锁 谁解锁 解铃还须系铃人
- 锁的作用就是对临界区资源的读写操作的安全限制
- 锁是否可以被多个使用者占用( 互不影响的使用者对资源的占用 )
- 占用资源的加锁者的释放问题 ( 锁持有的超时问题 )
- 等待资源的待加锁者的等待问题( 如何通知到其他等着资源的使用者 )
- 多个临界区资源锁的循环问题( 死锁场景 )
1.自旋锁
自旋锁是通过 CPU 提供的 CAS 函数(Compare And Swap),在「用户态」完成加锁和解锁操作,不会主动产生线程上下文切换,所以相比互斥锁来说,会快一些,开销也小一些。
一般加锁的过程,包含两个步骤:
- 第一步,查看锁的状态,如果锁是空闲的,则执行第二步;
- 第二步,将锁设置为当前线程持有;
CAS 函数就把这两个步骤合并成一条硬件级指令,形成原子指令,这样就保证了这两个步骤是不可分割的,要么一次性执行完两个步骤,要么两个步骤都不执行。
比如,设锁为变量 lock,整数 0 表示锁是空闲状态,整数 pid 表示线程 ID,那么 CAS(lock, 0, pid) 就表示自旋锁的加锁操作,CAS(lock, pid, 0) 则表示解锁操作。
使用自旋锁的时候,当发生多线程竞争锁的情况,加锁失败的线程会「忙等待」,直到它拿到锁。这里的「忙等待」可以用 while 循环等待实现,不过最好是使用 CPU 提供的 PAUSE 指令来实现「忙等待」,因为可以减少循环等待时的耗电量。
自旋锁是最比较简单的一种锁,一直自旋,利用 CPU 周期,直到锁可用。需要注意,在单核 CPU 上,需要抢占式的调度器(即不断通过时钟中断一个线程,运行其他线程)。否则,自旋锁在单 CPU 上无法使用,因为一个自旋的线程永远不会放弃 CPU。
自旋锁开销少,在多核系统下一般不会主动产生线程切换,适合异步、协程等在用户态切换请求的编程方式,但如果被锁住的代码执行时间过长,自旋的线程会长时间占用 CPU 资源,所以自旋的时间和被锁住的代码执行的时间是成「正比」的关系,我们需要清楚的知道这一点。
2.互斥锁
互斥锁是一种「独占锁」,比如当线程 A 加锁成功后,此时互斥锁已经被线程 A 独占了,只要线程 A 没有释放手中的锁,线程 B 加锁就会失败,于是就会释放 CPU 让给其他线程,既然线程 B 释放掉了 CPU,自然线程 B 加锁的代码就会被阻塞。
对于互斥锁加锁失败而阻塞的现象,是由操作系统内核实现的。当加锁失败时,内核会将线程置为「睡眠」状态,等到锁被释放后,内核会在合适的时机唤醒线程,当这个线程成功获取到锁后,于是就可以继续执行。
所以,互斥锁加锁失败时,会从用户态陷入到内核态,让内核帮我们切换线程,虽然简化了使用锁的难度,但是存在一定的性能开销成本。
那这个开销成本是什么呢?会有两次线程上下文切换的成本:
- 当线程加锁失败时,内核会把线程的状态从「运行」状态设置为「睡眠」状态,然后把 CPU 切换给其他线程运行;
- 接着,当锁被释放时,之前「睡眠」状态的线程会变为「就绪」状态,然后内核会在合适的时间,把 CPU 切换给该线程运行。
线程的上下文切换的是什么?当两个线程是属于同一个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据。
上下切换的耗时有大佬统计过,大概在几十纳秒到几微秒之间,如果你锁住的代码执行时间比较短,那可能上下文切换的时间都比你锁住的代码执行时间还要长。
所以,如果你能确定被锁住的代码执行时间很短,就不应该用互斥锁,而应该选用自旋锁,否则使用互斥锁。
3.读写锁
读写锁从字面意思我们也可以知道,它由「读锁」和「写锁」两部分构成,如果只读取共享资源用「读锁」加锁,如果要修改共享资源则用「写锁」加锁。
所以,读写锁适用于能明确区分读操作和写操作的场景。
读写锁的工作原理是:
- 当「写锁」没有被线程持有时,多个线程能够并发地持有读锁,这大大提高了共享资源的访问效率,因为「读锁」是用于读取共享资源的场景,所以多个线程同时持有读锁也不会破坏共享资源的数据。
- 但是,一旦「写锁」被线程持有后,读线程的获取读锁的操作会被阻塞,而且其他写线程的获取写锁的操作也会被阻塞。
所以说,写锁是独占锁,因为任何时刻只能有一个线程持有写锁,类似互斥锁和自旋锁,而读锁是共享锁,因为读锁可以被多个线程同时持有。
知道了读写锁的工作原理后,我们可以发现,读写锁在读多写少的场景,能发挥出优势。
另外,根据实现的不同,读写锁可以分为「读优先锁」和「写优先锁」。
-
读优先锁期望的是,读锁能被更多的线程持有,以便提高读线程的并发性,它的工作方式是:当读线程 A 先持有了读锁,写线程 B 在获取写锁的时候,会被阻塞,并且在阻塞过程中,后续来的读线程 C 仍然可以成功获取读锁,最后直到读线程 A 和 C 释放读锁后,写线程 B 才可以成功获取写锁。
-
而「写优先锁」是优先服务写线程,其工作方式是:当读线程 A 先持有了读锁,写线程 B 在获取写锁的时候,会被阻塞,并且在阻塞过程中,后续来的读线程 C 获取读锁时会失败,于是读线程 C 将被阻塞在获取读锁的操作,这样只要读线程 A 释放读锁后,写线程 B 就可以成功获取写锁。
读优先锁对于读线程并发性更好,但也不是没有问题。我们试想一下,如果一直有读线程获取读锁,那么写线程将永远获取不到写锁,这就造成了写线程「饥饿」的现象。
写优先锁可以保证写线程不会饿死,但是如果一直有写线程获取写锁,读线程也会被「饿死」。
既然不管优先读锁还是写锁,对方可能会出现饿死问题,那么我们就不偏袒任何一方,搞个「公平读写锁」。
公平读写锁比较简单的一种方式是:用队列把获取锁的线程排队,不管是写线程还是读线程都按照先进先出的原则加锁即可,这样读线程仍然可以并发,也不会出现「饥饿」的现象。
4.乐观锁与悲观锁
前面提到的互斥锁、自旋锁、读写锁,都是属于悲观锁。
悲观锁做事比较悲观,它认为多线程同时修改共享资源的概率比较高,于是很容易出现冲突,所以访问共享资源前,先要上锁。
那相反的,如果多线程同时修改共享资源的概率比较低,就可以采用乐观锁。
乐观锁做事比较乐观,它假定冲突的概率很低,它的工作方式是:先修改完共享资源,再验证这段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作。
放弃后如何重试,这跟业务场景息息相关,虽然重试的成本很高,但是冲突的概率足够低的话,还是可以接受的。
可见,乐观锁的心态是,不管三七二十一,先改了资源再说。另外,你会发现乐观锁全程并没有加锁,所以它也叫无锁编程。
这里举一个场景例子:在线文档。
我们都知道在线文档可以同时多人编辑的,如果使用了悲观锁,那么只要有一个用户正在编辑文档,此时其他用户就无法打开相同的文档了,这用户体验当然不好了。
那实现多人同时编辑,实际上是用了乐观锁,它允许多个用户打开同一个文档进行编辑,编辑完提交之后才验证修改的内容是否有冲突。
怎么样才算发生冲突?这里举个例子,比如用户 A 先在浏览器编辑文档,之后用户 B 在浏览器也打开了相同的文档进行编辑,但是用户 B 比用户 A 提交早,这一过程用户 A 是不知道的,当 A 提交修改完的内容时,那么 A 和 B 之间并行修改的地方就会发生冲突。
服务端要怎么验证是否冲突了呢?通常方案如下:
- 由于发生冲突的概率比较低,所以先让用户编辑文档,但是浏览器在下载文档时会记录下服务端返回的文档版本号;
- 当用户提交修改时,发给服务端的请求会带上原始文档版本号,服务器收到后将它与当前版本号进行比较,如果版本号一致则修改成功,否则提交失败。
实际上,我们常见的 SVN 和 Git 也是用了乐观锁的思想,先让用户编辑代码,然后提交的时候,通过版本号来判断是否产生了冲突,发生了冲突的地方,需要我们自己修改后,再重新提交。
乐观锁虽然去除了加锁解锁的操作,但是一旦发生冲突,重试的成本非常高,所以只有在冲突概率非常低,且加锁成本非常高的场景时,才考虑使用乐观锁。
5.RCU锁
RCU锁是读写锁的扩展版本,简单来说就是支持多读多写同时加锁,多读没什么好说的,但是对于多写同时加锁,还是存在一些技术挑战的。
RCU锁翻译为Read Copy Update Lock,读-拷贝-更新 锁。
Copy拷贝:写者在访问临界区时,写者将先拷贝一个临界区副本,然后对副本进行修改;
Update更新:RCU机制将在在适当时机使用一个回调函数把指向原来临界区的指针重新指向新的被修改的临界区,锁机制中的垃圾收集器负责回调函数的调用。
更新时机:没有CPU再去操作这段被RCU保护的临界区后,这段临界区即可回收了,此时回调函数即被调用。
从实现逻辑来看,RCU锁在多个写者之间的同步开销还是比较大的,涉及到多份数据拷贝,回调函数等,因此这种锁机制的使用范围比较窄,适用于读多写少的情况,如网络路由表的查询更新、设备状态表更新等,在业务开发中使用不是很多。
五、Linux线程同步
1.线程同步的概念
同步即协同步调,按预定的先后次序运行。
线程同步,指一个线程发出某一功能调用时,在没有得到结果之前,该调用不返回。同时其它线程为保证数据一致性,不能调用该功能。
举例1: 银行存款 5000。柜台,折:取3000;提款机,卡:取 3000。剩余:2000
举例2: 内存中100字节,线程T1欲填入全1, 线程T2欲填入全0。但如果T1执行了50个字节失去cpu,T2执行,会将T1写过的内容覆盖。当T1再次获得cpu继续 从失去cpu的位置向后写入1,当执行结束,内存中的100字节,既不是全1,也不是全0。
产生的现象叫做“与时间有关的错误”(time related)。为了避免这种数据混乱,线程需要同步。
竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。
同步的目的,是为了避免数据混乱,解决与时间有关的错误。实际上,不仅线程间需要同步,进程间、信号间等等都需要同步机制。
因此,所有“多个控制流,共同操作一个共享资源”的情况,都需要同步。
2.互斥与同步的区别
同步,又称直接制约关系,是指多个线程(或进程)为了合作完成任务,必须严格按照规定的某种先后次序来运行。
互斥,又称间接制约关系,是指系统中的某些共享资源,一次只允许一个线程访问。当一个线程正在访问该临界资源时,其它线程必须等待。
总结如下:
- 互斥是指某一资源同时只允许一个访问者对其进行访问,具有唯一性和排它性。但互斥无法限制访问者对资源的访问顺序,即访问是无序的。
- 同步是指在互斥的基础上(大多数情况),通过其它机制实现访问者对资源的有序访问。
- 同步其实已经实现了互斥,所以同步是一种更为复杂的互斥。
- 互斥是一种特殊的同步。
3.条件变量
a).条件变量是什么
条件变量本质也是一个全局变量,它的功能是阻塞线程,直至接收到“条件成立”的信号后,被阻塞的线程才能继续执行。
一个条件变量可以阻塞多个线程,这些线程会组成一个等待队列。当条件成立时,条件变量可以解除线程的“被阻塞状态”。也就是说,条件变量可以完成以下两项操作:
- 阻塞线程,直至接收到“条件成立”的信号;
- 向等待队列中的一个或所有线程发送“条件成立”的信号,解除它们的“被阻塞”状态。
b).具体用法
POSIX 标准中,条件变量用 pthread_cond_t 类型的变量表示,此类型定义在<pthread.h>头文件中。举个例子:
#include <pthread.h>
pthread_cond_t myCond;
由此,我们就成功创建了一个条件变量。要想使用 myCond 条件变量,还需要进行初始化操作。
初始化条件变量
初始化条件变量的方式有两种,一种是直接将 PTHREAD_COND_INITIALIZER 赋值给条件变量:
pthread_cond_t myCond = PTHREAD_COND_INITIALIZER;
还可以借助 pthread_cond_init() 函数初始化条件变量,语法格式如下:
int pthread_cond_init(pthread_cond_t * cond, const pthread_condattr_t * attr);
//参数 attr 用于自定义条件变量的属性,通常我们将它赋值为 NULL,表示以系统默认的属性完成初始化操作。返回值:成功返回 0,失败返回非零。
阻塞当前线程,等待条件成立
当条件不成立时,条件变量可以阻塞当前线程,所有被阻塞的线程会构成一个等待队列。
阻塞线程可以借助以下两个函数实现:
int pthread_cond_wait(pthread_cond_t* cond, pthread_mutex_t* mutex);
int pthread_cond_timedwait(pthread_cond_t* cond, pthread_mutex_t* mutex, const struct timespec* abstime);cond 参数表示已初始化好的条件变量;
mutex 参数表示与条件变量配合使用的互斥锁;
abstime 参数表示阻塞线程的时间。注意,abstime 参数指的是绝对时间,例如您打算阻塞线程 5 秒钟,那么首先要得到当前系统的时间,
然后再加上 5 秒,最终得到的时间才是传递的实参值。
调用两个函数之前,我们必须先创建好一个互斥锁并完成“加锁”操作,然后才能作为实参传递给 mutex 参数。两个函数会完成以下两项工作:
- 阻塞线程,直至接收到“条件成立”的信号;
- 当线程被添加到等待队列上时,将互斥锁“解锁”。
也就是说,函数尚未接收到“条件成立”的信号之前,它将一直阻塞线程执行。当函数接收到“条件成立”的信号后,它并不会立即结束对线程的阻塞,而是先完成对互斥锁的“加锁”操作,然后才解除阻塞。
两个函数都以“原子操作”的方式完成“阻塞线程+解锁”或者“重新加锁+解除阻塞”这两个过程。
以上两个函数都能用来阻塞线程,它们的区别在于:pthread_cond_wait() 函数可以永久阻塞线程,直到条件变量成立的那一刻;pthread_cond_timedwait() 函数只能在 abstime 参数指定的时间内阻塞线程,超出时限后,该函数将重新对互斥锁执行“加锁”操作,并解除对线程的阻塞,函数的返回值为 ETIMEDOUT。
如果函数成功接收到了“条件成立”的信号,重新对互斥锁完成了“加锁”并使线程继续执行,函数返回数字 0,反之则返回非零数。
解除线程的阻塞状态
对于被 pthread_cond_wait() 或 pthread_cond_timedwait() 函数阻塞的线程,我们可以借助如下两个函数向它们发送“条件成立”的信号,解除它们的“被阻塞”状态:
int pthread_cond_signal(pthread_cond_t* cond);
int pthread_cond_broadcast(pthread_cond_t* cond);
两个函数都能解除线程的“被阻塞”状态,区别在于:
- pthread_cond_signal() 函数至少解除一个线程的“被阻塞”状态,如果等待队列中包含多个线程,优先解除哪个线程将由操作系统的线程调度程序决定;
- pthread_cond_broadcast() 函数可以解除等待队列中所有线程的“被阻塞”状态。
由于互斥锁的存在,解除阻塞后的线程也不一定能立即执行。当互斥锁处于“加锁”状态时,解除阻塞状态的所有线程会组成等待互斥锁资源的队列,等待互斥锁“解锁”。
销毁条件变量
对于初始化好的条件变量,我们可以调用下面的函数销毁它。
int pthread_cond_destroy(pthread_cond_t *cond);
值得一提的是,销毁后的条件变量还可以调用 pthread_cond_init() 函数重新初始化后使用。
c).深入探讨
c.1.为什么 pthread_cond_wait 需要互斥量?
- 条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所以必须要有一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足,并且友好的通知等待在条件变量上的线程。
- 条件不会无缘无故的突然变得满足了,必然会牵扯到共享数据的变化。所以一定要用互斥锁来保护。没有互斥锁就无法安全的获取和修改共享数据。
按照上面的说法,我们设计出如下的代码:先上锁,发现条件不满足,解锁,然后等待在条件变量上不就行了,如下代码:
// 错误的设计
pthread_mutex_lock(&mutex);
while (condition_is_false) {pthread_mutex_unlock(&mutex);
//解锁之后,等待之前,条件可能已经满足,信号已经发出,但是该信号可能被错过pthread_cond_wait(&cond);pthread_mutex_lock(&mutex);
}
pthread_mutex_unlock(&mutex);
- 由于解锁和等待不是原子操作。调用解锁之后, 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,把互斥量恢复成原样。
c.2.传入前为什么要加锁?
传入前加锁是为了保证线程从条件判断到进入pthread_cond_wait前,条件不被其他线程改变,因为条件判断通常是借助线程共享变量,所以条件也属于临界资源。
c.3.条件不满足时,为何要解锁?(该动作由pthread_cond_wait自动完成)
条件不满足时,pthread_cond_wait解锁之后就进入阻塞状态。如果不解锁,会导致其他线程无法修改判断条件,从而导致死锁。
c.4.线程被唤醒之后,为何需要加锁呢?(该动作由pthread_cond_wait自动完成)
进入临界区自然需要加锁。
c.5.要使用 while 而不是 if,避免虚假唤醒!
细心观察可以发现,我们在等待的线程中,使用的是 while (条件不成立)
的方式来调用 wait 函数,而不是使用 if
语句。
这是由于 wait 函数被唤醒时,存在虚假唤醒等情况,导致唤醒后发现,条件依旧不成立。因此需要使用 while
语句来循环地进行等待,直到条件成立为止。
c.6.退出临界区时为何要解锁(这个动作需要我们完成)?
因为进来之时条件变量已经自动帮我们上了锁,但这个解锁动作要由我们自己完成。