🏠 大家好,我是 兔7 ,一位努力学习C++的博主~💬
🍑 如果文章知识点有错误的地方,请指正!和大家一起学习,一起进步👀
🚀 如有不懂,可以随时向我提问,我会全力讲解~
🔥 如果感觉博主的文章还不错的话,希望大家关注、点赞、收藏三连支持一下博主哦~!
🔥 你们的支持是我创作的动力!
🧸 我相信现在的努力的艰辛,都是为以后的美好最好的见证!
🧸 人的心态决定姿态!
🚀 本文章CSDN首发!
目录
0. 前言
此博客为博主以后复习的资料,所以大家放心学习,总结的很全面,每段代码都给大家发了出来,大家如果有疑问可以尝试去调试。
大家一定要认真看图,图里的文字都是精华,好多的细节都在图中展示、写出来了,所以大家一定要仔细哦~
感谢大家对我的支持,感谢大家的喜欢, 兔7 祝大家在学习的路上一路顺利,生活的路上顺心顺意~!
1. Linux线程概念
1.1 什么是线程
- 在一个程序里的一个执行路线就叫做线程(thread)。更准确的定义是:线程是“一个进程内部的控制序列”。
- 一切进程至少都有一个执行线程。
- 线程在进程内部运行,本质是在进程地址空间内运行。
- 在Linux系统中,在CPU眼中,看到的PCB都要比传统的进程更加轻量化。
- 透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流。
那么在 Linux 中,站在CPU的角度,能不能识别 task_struct 是进程还是线程呢?
不能,也不需要了,CPU只关心一个个的独立执行流!
在CPU看来,task_struct <= os 原理上面的进程控制块的。
所以在 Linux 中的所有执行流都叫做轻量级进程!
而且要说的是,在 Linux 下,并不存在真正的多线程!接下来解释一下:
如果支持真的线程,当线程足够多的时候,OS是要管理线程的。
换句话说就是在创建线程、终止线程 ... ... 相比于进程,都要重新的进行设置一个模块,这样就会大大的提升操作系统的设计复杂程度!
所以操作系统就发现线程跟进程的需求差不多,所以线程就复用了进程的逻辑。也就是线程是用进程模拟的!
(在Windows 下存在真正的线程,所以也就决定了 Windows 操作系统的设计复杂度要比 Linux 高很多)
既然 Linux 并没有真正意义上的线程,所以 Linux 也绝对没有真正意义上的线程相关的系统调用!
所以 Linux 肯定会提供创建轻量级进程的接口去创建进程、共享空间。接口就是 vfork() :父子共享空间。
上面说的都是站在内核角度,那么如果站在用户角度呢?用户肯定不能说我要创建一个轻量级进程吧,肯定是说创建一个进程。
所以 Linux 就提供了一个原生线程库(第三方库)的方案解决,就是基于轻量级进程的系统调用,通过在用户层模拟实现一套线程接口,这个库就是 phtread 库。
那么接下来先插入一段对页表的讲解:
所以肯定步是通过一张表完成的,那么接下来就看看是怎么完成的:
在 Linux 32位下用的是二级页表,64 位可能就是用多级页表了。
我们之前只知道常量(只读)字符串不可以被修改,但是具体为什么不可以呢?可能很多人不少很理解:
1.2 线程的优点
- 创建一个新线程的代价要比创建一个新进程小得多
- 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
- 线程占用的资源要比进程少很多
- 能充分利用多处理器的可并行数量
- 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
- I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作
1.3 线程的缺点
- 性能损失
一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。 - 健壮性降低
编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。 - 缺乏访问控制
进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。 - 编程难度提高
编写与调试一个多线程程序比单线程程序困难得多。
1.4 线程异常
- 单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃
- 线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出
1.5 线程用途
- 合理的使用多线程,能提高CPU密集型程序的执行效率
- 合理的使用多线程,能提高IO密集型程序的用户体验(如生活中我们一边写代码一边下载开发工具,就是多线程运行的一种表现)
2. Linux进程VS线程
2.1 进程和线程
1. 进程是资源分配的基本单位
2. 线程是调度的基本单位
3. 线程共享进程数据,但也拥有自己的一部分数据:
- 线程ID
- 一组寄存器(线程的切换是要进行上下文保存的)
- 栈(每个线程的运行相对上也是一种互相独立的状态,因为产生的临时数据不会互相干扰)
- errno
- 信号屏蔽字
- 调度优先级
2.2 进程的多个线程共享
同一地址空间,因此Text Segment、Data Segment都是共享的,如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境:
- 文件描述符表
- 每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)
- 当前工作目录
- 用户id和组id
进程和线程的关系如下图:
2.3 关于进程线程的问题
如何看待之前学习的单进程?
具有一个线程执行流的进程。
3. Linux线程控制
3.1 POSIX线程库
- 与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以“pthread_”打头的
- 要使用这些函数库,要通过引入头文
- 链接这些线程函数库时要使用编译器命令的“-lpthread”选项
3.2 创建线程
功能:创建一个新的线程
原型:
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;失败返回错误码
错误检查:
- 传统的一些函数是,成功返回0,失败返回-1,并且对全局变量errno赋值以指示错误。
- pthreads函数出错时不会设置全局变量errno(而大部分其他POSIX函数会这样做)。而是将错误代码通过返回值返回。
- pthreads同样也提供了线程内的errno变量,以支持其它使用errno的代码。对于pthreads函数的错误,建议通过返回值业判定,因为读取返回值要比读取线程内的errno变量的开销更小 。
接下来我们就用一下:
1 #include<stdio.h>
2 #include<pthread.h>
3 #include<unistd.h>
4
5 void* Routine(void *arg)
6 {
7 char *msg = (char*)arg;
8 while(1){
9 printf("%s\n", msg);
10 sleep(1);
11 }
12 }
13
14 int main()
15 {
16 pthread_t tid;
17 pthread_create(&tid, NULL, Routine, (void*)"thread 1");
18
19 while(1){
20 printf("I am main thread!\n");
21 sleep(2);
22 }
23
24 return 0;
25 }
我们知道,如果只有一个线程,是不可能同时运行两个 while 循环的,所以:
我们可以看到只有一个进程,但同时运行了两个 while 循环,所以肯定是创建了一个线程,主线程和创建的线程分别运行 while 循环。
那么下面来证明一下线程是在进程内运行,或者说是所有线程都有自己的执行流,但是再有执行流,所有的线程都属于同一个进程。
所以这时就可以用 getpid 接口来看这两个线程的 pid 是不是相同的,如果是相同的,那么就确定了这两个线程都属于一个进程:
1 #include<stdio.h>
2 #include<pthread.h>
3 #include<unistd.h>
4 #include<sys/types.h>
5
6
7
8 void* Routine(void *arg)
9 {
10 char *msg = (char*)arg;
11 while(1){
12 printf("%s: pid: %d, ppid: %d\n", msg, getpid(), getppid());
13 sleep(1);
14 }
15 }
16
17 int main()
18 {
19 pthread_t tid;
20 pthread_create(&tid, NULL, Routine, (void*)"thread 1");
21
22 while(1){
23 printf("main thread: pid: %d, ppid: %d\n", getpid(), getppid());
24 sleep(2);
25 }
26
27 return 0;
28 }
我们可以看到,它们的 pid 确实是相同的,所以这两个线程都属于同一个进程。
那么怎么看到这两个线程呢:在 ps 查看的时候带上 -L(轻量级)
我们可以看到 PID ,还能看到一个 LWP(Light weight processes) ,这个 LWP 就是轻量级进程 id 。
所以实际上操作系统调度上,根本看的就不是 PID ,所以 OS 调度的时候,采用的是 LWP !并非是 PID 。因为一个 PID 对应了多个 id。不过之前都是用的单进程、单线程,所以 PID 和 LWP 是相等的,所以如果是单进程、单线程,看 PID 和看 LWP 是一样的。
Linux 中,应用层的线程与内核的 LWP 是1:1的关系!
当然,上面只是创建了一个线程,我们也可以创建多个线程:
那么如果创建了多个线程,那么多个线程都会执行 Routine ,也就是说 Routine 会被重复进入,那么该函数在这个时候是会被重入的!
1 #include<stdio.h>
2 #include<pthread.h>
3 #include<unistd.h>
4 #include<sys/types.h>
5
6 void* Routine(void *arg)
7 {
8 char *msg = (char*)arg;
9 while(1){
10 printf("%s: pid: %d, ppid: %d\n", msg, getpid(), getppid());
11 sleep(1);
12 }
13 }
14
15 int main()
16 {
17 pthread_t tid[5];
18
19 for(int i = 0; i < 5; i++){
20 char* buffer = (char*)malloc(64);
21 sprintf(buffer, "thread %d", i);
22 pthread_create(&tid[i], NULL, Routine, (void*)buffer);
23 }
24 while(1){
25 printf("main thread: pid: %d, ppid: %d\n", getpid(), getppid());
26 sleep(2);
27 }
28
29 return 0;
30 }
先要注意上面的 buffer 不要用数组,如果用数组的话就会是下面的效果:
这是因为传参的时候传的是指针,for 循环的时候 sprintf 要对 buffer 进行覆盖,所以最后打印出来的都会是 thread 4 这样。所以直接用 malloc 就可以解决这个问题。
那么此时我们就可以看到,创建了 5 个线程,加上主线程一共 6 个线程,每个线程都是一个执行流。
其实每个线程都有自己的线程 id 的,可以通过下面的函数进行使用:
1 #include<stdio.h>
2 #include<pthread.h>
3 #include<unistd.h>
4 #include<sys/types.h>
5 #include<stdlib.h>
6
7 void* Routine(void *arg)
8 {
9 char *msg = (char*)arg;
10 while(1){
11 sleep(1);
W> 12 printf("%s: pid: %d, ppid: %d, tid: %x\n", msg, getpid(), getppid(), pthread_self());
13 }
14 }
15
16 int main()
17 {
18 pthread_t tid[5];
19
20 for(int i = 0; i < 5; i++){
21 char* buffer = (char*)malloc(64);
22 sprintf(buffer, "thread %d", i);
23 pthread_create(&tid[i], NULL, Routine, (void*)buffer);
W> 24 printf("%s tid is: %x\n", buffer, tid[i]);
25 }
26 while(1){
W> 27 printf("main thread: pid: %d, ppid: %d, tid: %x\n", getpid(), getppid(), pthread_self());
28 sleep(2);
29 }
30
31 return 0;
32 }
前面不是说 LWP 是内核上面的一个概念用来标定某一个轻量级进程么,而我们在用户层看到的 tid 是上图所示的样子,很显然 tid 和 LWP 是不会相等!
所以 pthread_self() 获得的是用户级原生线程库的线程 ID 。
其实上面的写法在主线程的时候用的死循环,但是正常情况下这种情况很少,所以这样写其实是有问题的,但是因为给线程分配任务后,线程去执行 Routine 的时候,主线程直接运行到 return 0 了,那么就有很大问题了!
所以其实线程跟进程一样,也需要被等待!
而且如果一直运行,新线程退出了,主线程不管,其中这个线程的资源也不会回收的,所以线程也需要被等待,如果不等待,也会产生类似于僵尸进程的一个东西。相当于内存泄漏,而且线程的退出信息也无法获得。
3.3 等待进程
那么其实线程等待用到的接口:
1 #include<stdio.h>
2 #include<pthread.h>
3 #include<unistd.h>
4 #include<sys/types.h>
5 #include<stdlib.h>
6
7 void* Routine(void *arg)
8 {
9 char *msg = (char*)arg;
10 sleep(1);
W> 11 printf("%s: pid: %d, ppid: %d, tid: %x\n", msg, getpid(), getppid(), pthread_self());
W> 12 }
13
14 int main()
15 {
16 pthread_t tid[5];
17
18 for(int i = 0; i < 5; i++){
19 char* buffer = (char*)malloc(64);
20 sprintf(buffer, "thread %d", i);
21 pthread_create(&tid[i], NULL, Routine, (void*)buffer);
W> 22 printf("%s tid is: %x\n", buffer, tid[i]);
23 }
W> 24 printf("main thread: pid: %d, ppid: %d, tid: %x\n", getpid(), getppid(), pthread_self());
25
26 for(int i = 0; i < 5; i++){
27 pthread_join(tid[i], NULL);
W> 28 printf("thread %d[%x] ... quit!\n", i, tid[i]);
29 }
30 return 0;
31 }
这就完成了线程等待的一个基本过程。
当然上面没有用到 pthread_join 的第二个参数,那么可以看到它的类型是 void **,那么其实我们要传入的是类型位 void* 的参数,用来获得退出码:
1 #include<stdio.h>
2 #include<pthread.h>
3 #include<unistd.h>
4 #include<sys/types.h>
5 #include<stdlib.h>
6
7 void* Routine(void *arg)
8 {
9 char *msg = (char*)arg;
10 sleep(1);
W> 11 printf("%s: pid: %d, ppid: %d, tid: %x\n", msg, getpid(), getppid(), pthread_self());
12 sleep(1);
13 return (void*)77;
14 }
15
16 int main()
17 {
18 pthread_t tid[5];
19
20 for(int i = 0; i < 5; i++){
21 char* buffer = (char*)malloc(64);
22 sprintf(buffer, "thread %d", i);
23 pthread_create(&tid[i], NULL, Routine, (void*)buffer);
W> 24 printf("%s tid is: %x\n", buffer, tid[i]);
25 }
W> 26 printf("main thread: pid: %d, ppid: %d, tid: %x\n", getpid(), getppid(), pthread_self());
27
28 for(int i = 0; i < 5; i++){
29 void* ret = NULL;
30 pthread_join(tid[i], &ret);
E> 31 printf("thread %d[%x] ... quit!, code: %d\n", i, tid[i], (int)ret);
32 }
33 return 0;
34 }
可以看到,获得了退出码,也就是说主线程可以得知新线程完成任务的情况。
其实这里 pthread_join() 的第二个参数是拿到被等待的线程的退出码,是用来检测代码运行完毕,结果是否正确的,我们在写的时候,不考虑异常的情况。不是多线程不需要考虑,而是做不到!
因为在前面就说过,当一个线程异常的时候,硬件的异常就会被操作系统发现,然后操作系统给进程发信号,直接就将进程终止了。
也就是说一个线程异常后,整个进程就退出了,所以其实在 Routine 的时候就异常了,根本就没有等到 pthread_join() 进行接收呢,进程就退出了!
我们可以看到这个情况!这也就对应了线程缺点中的健壮性减低的问题!
上面也完成了线程的等待,那么线程如何终止呢?
当前现在只讨论正常终止。
因为主线程一退出,新线程都会一起退出,资源被回收,所以就看到这个情况。
当我们用 exit() 的时候会发现最后还是少了点东西,就是虽然线程是退出了,但是最后 pthread_join() 并没有看到任何等待的结果。
其实是因为 exit() 是终止整个进程,线程去调用 exit() 后也是终止进程,所以用线程一般不用 exit() ,因为一调用整个进程就终止了。
3.4 线程终止
所以我们要终止线程要用的是 pthread_exit() 。
还有一种方法就是 phtread_cancel() 。
我们可以看到接收的返回值是 0 。
那么我们看一下线程取消是不是成功的:
我们可以看到 pthread_cancel 的退出码是 -1。
这里其实是因为一般 pthread_cancel 不在末端去取消线程,一般在中间运行中就去取消,也就是说 pthread_cancel 后面要有代码。
这个也是能理解的,因为在代码最后一行才取消,其实本身就到了最结尾,已经要退出了。
所以一般情况下,线程取消后退出码一般为 -1 是取消成功。
但是一个线程退出有必要这么多的形式么,一个兼容C语言的 return,一个 pthread_exit() 不就够了么。
所以其实这个线程取消一般也不是这样用的,不是自己去取消自己,而是让主线程去取消其他线程。
当然这里就可能有小伙伴有奇怪的问题,那就是新线程能不能取消主线程:
我们可以看到第一次取消的时候退出码是 0 ,后面的都是 3 了,因为第一次取消成功后,后面再取消就失败了,而且取消之后会发现看到主线程后面出现一个 <defunct> (非现存的、失灵的、不再使用的、死的)。
所以我们经过检测发现新线程确实是可以取消主线程的,但是一般没有这么干的,这么就就是奇奇怪怪,没人这么写。
如果需要只终止某个线程而不终止整个进程,可以有三种方法:
- 从线程函数return。这种方法对主线程不适用,从main函数return相当于调用exit。
- 线程可以调用pthread_ exit终止自己。
- 一个线程可以调用pthread_ cancel终止同一进程中的另一个线程。
一般情况下,线程必须被等待,就如同子进程必须被等待一般!
为什么需要线程等待?
- 已经退出的线程,其空间没有被释放,仍然在进程的地址空间内。
- 创建新的线程不会复用刚才退出线程的地址空间。
调用该函数的线程将挂起等待,直到 id 为 thread 的线程终止。thread 线程以不同的方法终止,通过 pthread_join 得到的终止状态是不同的,总结如下:
- 如果thread线程通过 return 返回, value_ ptr 所指向的单元里存放的是 thread 线程函数的返回值。
- 如果 thread 线程被别的线程调用 pthread_ cancel 异常终掉, value_ ptr 所指向的单元里存放的是常数 PTHREAD_ CANCELED 。
- 如果thread线程是自己调用 pthread_exit 终止的, value_ptr 所指向的单元存放的是传给 pthread_exit 的参数。
- 如果对 thread 线程的终止状态不感兴趣,可以传NULL给 value_ ptr 参数。
进程如果不相等也是可以的,在进程那块有一个方法是:子进程退出之后会给父进程发送 SIGCHLD ,如果将 SIGCHLD 忽略(IGN)了,也就意味着当子进程退出时不用显示的 waitpid 了。
同样,线程如果你想的话也可以不用,不过需要将线程进行分离!
3.5 分离线程
1 #include<stdio.h>
2 #include<pthread.h>
3 #include<unistd.h>
4 #include<sys/types.h>
5 #include<stdlib.h>
6
7 //pthread_t main_thread;
8
9 void* Routine(void *arg)
10 {
11 pthread_detach(pthread_self());
12 char *msg = (char*)arg;
13 for(int i = 0; i < 3; i++){
14 sleep(1);
W> 15 printf("%s: pid: %d, ppid: %d, tid: %x\n", msg, getpid(), getppid(), pthread_self());
16 sleep(1);
17 //int ret = pthread_cancel(main_thread);
18 //printf("man_thread code: %d\n", ret);
19 }
20 pthread_exit((void*)77);
21 }
22
23 int main()
24 {
25 //main_thread = pthread_self();
26 pthread_t tid[5];
27
28 for(int i = 0; i < 5; i++){
29 char* buffer = (char*)malloc(64);
30 sprintf(buffer, "thread %d", i);
31 pthread_create(&tid[i], NULL, Routine, (void*)buffer);
W> 32 printf("%s tid is: %x\n", buffer, tid[i]);
33 }
W> 34 printf("main thread: pid: %d, ppid: %d, tid: %x\n", getpid(), getppid(), pthread_self());
35
36 for(int i = 0; i < 5; i++){
37 void* ret = NULL;
38 pthread_join(tid[i], &ret);
E> 39 printf("thread %d[%x] ... quit!, code: %d\n", i, tid[i], (int)ret);
40 }
41 return 0;
42 }
最后的这个退出码虽然返回的还是 77 ,但是其实是没有意义的,这个退出码就是凑巧得到的,然后会发现最后出现了错误。其实是因为我们已经将线程分离了,所以主线程最后就不用去 pthread_join() 了,只要我们将那段代码屏蔽掉就可以了。
我们可以看到开始 6 个线程一起运行,最后 5 个线程都被分离了,最终它们都会退出,然后最后可以看到就剩下一个主线程了。
3.6 总结
所以我们可以看到所谓的 thread_t 本质是一个地址!!
多线程是由 LWP 标识的,那么用户层如何想得知对应的线程,肯定需要OS对线程进行管理,因为线程很多。所以需要OS对线程进行先描述,再组织。
又因为 Linux 不提供真正的线程,只提供 LWP ,意味着 OS 只需要对 LWP 内核执行流进行管理。那么供用户使用的接口等其他数据肯定是由线程库->pthread库来管理。所以先描述,再组织肯定就是在库里面。
而且我们还知道 pthread 库在 Linux 下就是一个文件,所以在使用的时候是要从磁盘中加载到内存里的,然后通过页表映射到堆栈之间的共享内存那个区域。
所以我们其实要找到一个用户级线程只需要找到每一个描述该线程的内存块的起始地址,然后拿到里面的线程属性、上下文数据、私有栈,那么线程的数据就全部找到了。
所以我们上面用到的接口都是在库里对线程属性的操作。
所以线程的线程 id 本质是一个映射进地址空间共享区的 pthread 库的特定地址,所有的地址都是不同的,那么就可以唯一区分每一个线程了,其中我们看到的这个线程是一个用户级别的,因为这个地址空间是用户区,所以大部分的调用都是在用户区完成的,小部分的调用是通过 LWP 在内核区去运行的。
4. Linux线程互斥
4.1 进程线程间的互斥相关背景概念
- 临界资源:多线程执行流共享的资源就叫做临界资源
- 临界区:每个线程内部,访问临界自娱的代码,就叫做临界区
- 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
- 原子性(后面讨论如何实现):不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成
4.2 互斥量mutex
- 大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。
- 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。
- 多个线程并发的操作共享变量,会带来一些问题。
下面来模拟一个抢票的逻辑:
1 #include<stdio.h>
2 #include<pthread.h>
3 #include<unistd.h>
4 #include<sys/types.h>
5 #include<stdlib.h>
6
7 int tickets = 1000;
8
9 void* TicketGrabbing(void* arg)
10 {
11 const char* name = (char*)arg;
12 while(1){
13 if(tickets > 0){
14 usleep(1000);
15 printf("[%s] get a ticket: %d\n", name, tickets--);
16 }
17 else{
18 break;
19 }
20 }
21 printf("%s quit!\n", name);
22 pthread_exit((void*)0);
23 }
24
25 int main()
26 {
27 pthread_t t1, t2, t3, t4;
E> 28 pthread_create(&t1, NULL, TicketGrabbing, "thread 1");
E> 29 pthread_create(&t2, NULL, TicketGrabbing, "thread 2");
E> 30 pthread_create(&t3, NULL, TicketGrabbing, "thread 3");
E> 31 pthread_create(&t4, NULL, TicketGrabbing, "thread 4");
32
33 pthread_join(t1, NULL);
34 pthread_join(t2, NULL);
35 pthread_join(t3, NULL);
36 pthread_join(t4, NULL);
37
38 return 0;
39 }
我们可以看到,通过上面写的抢票操作,都已经抢到了 0 -1 -2 ,按理来说抢到 1 就没有票了,也就不该抢了,那么现在抢到了 0 -1 -2 说明是有问题的。
这里的 tickets 变量称为临界资源,访问 tickets 的代码称为临界区。
那么下面就有两个问题:
- tickets-- 是原子操作么?
- if(tickets > 0){} 是原子的么?有没有可能其它线程正在进行 -- ?
我们在计算的时候,只有CPU具有计算能力(不考虑GPU... ...),存储就是在内存中存储。
所以 tickets 肯定是在内存中的,tickets-- 是自减,也就是 -- 完之后自己也要发生变化 。
所以操作上实际是CPU先把 tickets 内的数据读取到 CPU 的寄存器中,然后再将 tickets - 1,最后再将的出来的数值写回给内存。
换言之,一个 tickets-- 至少要经过三步才能完成,那么当前的线程就有可能刚将 tickets 的数据读到寄存器中然后就被OS剥夺了,然后再执行其它的线程,那么其它的线程此时也是拿到的是没有 -- 之前的数值,那么此时第二个线程对其 -- 了,然后将 -- 后的值写回内存,然后在切回第一个线程的时候,还是对 tickets 最开始的值进行 -- ,最后写回内存中。
所以此时就相当于有几张票卖了两次!也就是凭空多了几张票,显然是不合理的!
接下来看一下反汇编:
所以 tickets-- 不是原子的!
那么现在回答第二个问题,其实看起来 if(tickets > 0){} 像是原子的,但是现在想想,这个判断跟 tickets-- 的原理还是很像的,也是先将 tickets 加载到寄存器里,然后再计算(跟 0 比较),然后再返回。
那么如果你刚放到寄存器里,然后就切到其它线程了,然后其它线程将票抢完了,但是此时你寄存器的值是大于 0 的,那么就还会进入 if 里去对 tickets--,就会出现最开始的情况。
所以 if(tickets > 0){} 也不是原子的!
要解决以上问题,需要做到三点:
- 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
- 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
- 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量。
4.2.1 互斥量的接口
因为加锁是有损于性能的,但是不加锁就会出错,所以我们选择的是性能的损失,这个是不可避免的,但是我们也要减少对性能的损失。
1 #include<stdio.h>
2 #include<pthread.h>
3 #include<unistd.h>
4 #include<sys/types.h>
5 #include<stdlib.h>
6
7 #define NUM 2000
8
9 int tickets = NUM;
10 pthread_mutex_t lock;
11
12 void* TicketGrabbing(void* arg)
13 {
E> 14 int number = (int)arg;
15 while(1){
16 pthread_mutex_lock(&lock);
17 if(tickets > 0){
18 usleep(100);
19 printf("thread[%d] 抢票: %d\n", number, tickets--);
20 pthread_mutex_unlock(&lock);
21 }
22 else{
23 pthread_mutex_unlock(&lock);
24 break;
25 }
26 }
27 }
28
29 int main()
30 {
31 pthread_t thds[5];
32 pthread_mutex_init(&lock, NULL);
33
34 for(int i = 0 ; i < 5; i++){
W> 35 pthread_create(&thds[i], NULL, TicketGrabbing, (void*)i);
36 }
37
38 for(int i = 0; i < 5; i++){
39 pthread_join(thds[i], NULL);
40 }
41
42
43 pthread_mutex_destroy(&lock);
44 return 0;
45 }
我们可以看到,这样就完成了加锁功能,怎么运行都不会出现 0 -1 -2 的情况!
而且这里我们创建的 5 个线程都是对临界资源进行保护的,保护临界资源是所有执行流都应该遵守的准则!
接下来就说说加锁后的原子性体现在三个方面:
1:
当 1 线程拿到锁后进入抢票后,其它线程都不能进入,那么这时就有两个状态对于其它线程是有意义的,分别是:没有申请锁、锁已经释放了。
这点对于其它线程对于拿到锁的线程来说就是原子的。
2:
虽然线程 1 在临界区内有可能进行线程切换,但是即便是当前线程被切走,其它线程也无法进入临界区进行资源访问,因为当前进程是拿着锁进入的,那么即使是被切换了,其它线程也无法进入,所以其它线程要想进行访问临界资源就必须要等获得锁的人回来才有机会去访问!
这就是互斥的功能。
3:
lock 是全局变量,所有的线程需要通过访问 lock 来判断可以不可以进行访问,但是这里就有一个问题,因为所有的线程需要争夺锁来判断谁去访问,那么也就是说锁本身就是临界资源,锁的存在就是为了保护临界资源,但是锁本身就是临界资源!
那么锁是否需要保护呢?谁来保护锁呢?
答案是肯定的,只要是临界资源就需要被保护,只不过保护锁的方式不一样,也就是申请锁的过程必须是原子的!
接下来就要解释一下申请锁的过程为什么是原子的:
经过上面的例子,大家已经意识到单纯的 i++ 或者 ++i 都不是原子的,有可能会有数据一致性问题为了实现互斥锁操作,大多数体系结构都提供了 swap 或 exchange 指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。现在我们把 lock 和 unlock 的伪代码改一 下:
就是开始的时候 %al 这个寄存器设为 0 ,然后将寄存器里的值和 mutex 内的值交换,mutex 开始是有锁的,就用 1 表示,因为这里的 swap 和 exchange 是之间交换,就一步完成,所以交换的时候不会受切换的影响。
而且通过交换这一命令完成加锁,因为在所有的 寄存器和 mutex 中只存在一个 " 1 ",所以无论如何也就只有一个线程可以获得锁。
让运行完后,解锁就是切到有锁的上下文进行寄存器和 mutex 的交换,完成解锁过程。
这里要清楚,CPU中的寄存器是不被所有线程共享的,是每个线程私有的!
而且要知道所有的线程都可以交换,但是必须要有顺序,顺序是由指令周期决定的。
所以 lock 和 unlock 的具体过程是:把共享 mutex 通过 exchange 方案,原子性的交换到线程自己的上下文中。
5. 可重入 VS 线程安全
5.1 概念
- 线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
- 重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
这两个看起来很像,但其实是两个完全不同的概念!
线程安全:线程执行代码是否安全情况。
重入:函数被重复进入。(信号也会导致重入)
5.2 常见的线程不安全的情况
- 不保护共享变量的函数
- 函数状态随着被调用,状态发生变化的函数
- 返回指向静态变量指针的函数
- 调用线程不安全函数的函数
5.3 常见的线程安全的情况
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的。
- 类或者接口对于线程来说都是原子操作。
- 多个线程之间的切换不会导致该接口的执行结果存在二义性。
5.4 常见不可重入的情况
- 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
- 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
- 可重入函数体内使用了静态的数据结构
5.5 常见可重入的情况
- 不使用全局变量或静态变量
- 不使用用malloc或者new开辟出的空间
- 不调用不可重入函数
- 不返回静态或全局数据,所有数据都有函数的调用者提供
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
5.6 可重入与线程安全联系
- 函数是可重入的,那就是线程安全的
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
- 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的
5.7 可重入与线程安全区别
- 可重入函数是线程安全函数的一种
- 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
- 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生 死锁,因此是不可重入的。
6. 常见锁概念(死锁)
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。
我们可以看到,如果我们不小心写了两次锁,就可以看到被挂起了。
这是因为第一次申请锁成功了,第二次再申请的时候申请失败了,所以当前线程就被挂起了,但是这个线程被挂起的时候是拿着锁被挂起的,所以就成了死锁。
接下来再说一下什么叫阻塞:
在OS视角:进程线程等待某种资源,在OS层面就是将当前的进程或者线程 task_struct 放入对应的等待队列! R -> S,这种情况可以称之为当前进程被挂起等待了!
在用户视角:用户看到的是自己的进程卡住不动了,一般称为应用阻塞了!
6.1 死锁四个必要条件
- 互斥条件:一个资源每次只能被一个执行流使用
- 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
- 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
- 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
避免死锁就是破坏这四个必要条件中的一个,第一个条件很难去破坏,因为这个条件就是使用锁的条件,所以如果可以的话,尽量少使用锁,而且锁也是有损于性能的。
其它三个条件都可以尝试去避免。
6.2 避免死锁
- 破坏死锁的四个必要条件
- 加锁顺序一致
- 避免锁未释放的场景
- 资源一次性分配
这几个都是编程建议,也就是在编程的时候这么做了,也不一定会避免死锁的情况,只不过是会减少这种情况。
6.3 避免死锁算法
- 死锁检测算法(了解)
- 银行家算法(了解)
7. Linux线程同步
7.1 条件变量
- 当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。
- 例如一个线程访问队列时,发现队列为空,它只能等待,只到其它线程将一个节点添加到队列中。这种情况就需要用到条件变量。
为什么要存在同步?单纯的加锁有没有问题?
有问题,因为有可能个别线程竞争力很强,每次都能申请到锁,但是就是不办事,有可能导致其它线程长时间竞争不到锁,引起饥饿问题!虽然这样没有错,但是是低效的。
7.2 同步概念与竞态条件
- 同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步。
- 竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。在线程场景下,这种问题也不难理解。
7.3 条件变量函数
初始化
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);
1 #include <iostream>
2 #include <pthread.h>
3 #include <cstdio>
4 pthread_mutex_t lock;
5 pthread_cond_t cond;
6
7 void* Run(void* arg)
8 {
9 pthread_detach(pthread_self());
10 std::cout << (char*)arg << " run..." << std::endl;
11 while(true){
12 pthread_cond_wait(&cond, &lock);// 阻塞在这
13 std::cout << "thread: " << pthread_self() << "活动..." << std::endl;
14 }
15 }
16
17 int main()
18 {
19 pthread_mutex_init(&lock, nullptr);
20 pthread_cond_init(&cond, nullptr);
21
22 pthread_t t1, t2, t3;
23 pthread_create(&t1, nullptr, Run, (void*)"thread 1");
24 pthread_create(&t2, nullptr, Run, (void*)"thread 2");
25 pthread_create(&t3, nullptr, Run, (void*)"thread 3");
26
27 //ctrl
28 while(true){
29 getchar();
30 pthread_cond_signal(&cond);//唤醒等待队列的第一个线程
31 }
32
33 pthread_mutex_destroy(&lock);
34 pthread_cond_destroy(&cond);
35
36 return 0;
37 }
我们可以看到,打印出来的都十分由规律,但是我没有对线程进行排队。那么为什么会有规律呢?
其实是因为这若干个线程启动时,默认都会在该条件变量(cond)下去等待,当唤醒的时候(pthread_cond_signal(&cond)),都依此唤醒了在当前条件变量下等待的头部线程。而且线程运行完后会链到队列的结尾,周而复始的运行就是这种情况。
当然我们上面用的 pthread_cond_wait() 只是唤醒了一个线程,当然也可以唤醒所有线程,就有一个 pthread_cond_broadcast() 。
这样就完成了。
8. 生产者消费者模型
8.1 为何要使用生产者消费者模型
生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。这个阻塞队列就是用来给生产者和消费者解耦的。
这里就有三种关系、两种角色、一个交易场所:
三种关系:
- 消费者和消费者(竞争关系、互斥关系)
- 生产者和生产者(竞争关系、互斥关系)
- 生产者和消费者(竞争关系(数据的正确)、同步关系(多线程协同))
两种角色:
- 生产者
- 消费者
一个交易场所:
- 通常是内存中的一段缓冲区,自己通过某种方式组织起来。
8.2 生产者消费者模型优点
解耦
支持并发
支持忙闲不均
8.3 基于BlockingQueue的生产者消费者模型
在多线程编程中阻塞队列(Blocking Queue)是一种常用于实现生产者和消费者模型的数据结构。其与普通的队列区别在于,当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中被放入了元素;当队列满时,往队列里存放元素的操作也会被阻塞,直到有元素被从队列中取出(以上的操作都是基于不同的线程来说的,线程在对阻塞队列进程操作时会被阻塞)
(这个的应用场景多用于管道)
8.4 C++ queue模拟阻塞队列的生产消费模型 代码
BlockQueue.hpp
1 #pragma once
2
3 #include <iostream>
4 #include <ctime>
5 #include <queue>
6 #include <cstdlib>
7 #include <pthread.h>
8 #include <unistd.h>
9
10 #define NUM 5
11
12 template<typename T>
13 class BlockQueue
14 {
15 private:
16 bool IsFull()
17 {
18 return q.size() == _cap;
19 }
20
21 bool IsEmpty()
22 {
23 return q.empty();
24 }
25 public:
26 BlockQueue(int cap = NUM)
27 :_cap(cap)
28 {
29 pthread_mutex_init(&lock, nullptr);
30 pthread_cond_init(&full, nullptr);
31 pthread_cond_init(&empty, nullptr);
32 }
33
34 void Push(const T& in)
35 {
36 pthread_mutex_lock(&lock);
37 while(IsFull()){
38 //wait
39 // pthread_cond_wait 两个功能
40 // 一个功能是在这个条件变量上等待
41 // 第二个功能是释放这个互斥锁
42 // 如果当前等待线程被唤醒,会自动获得对应的mutex
43 pthread_cond_wait(&full, &lock);
44 }
45 q.push(in);
46 pthread_mutex_unlock(&lock);
47 pthread_cond_signal(&empty);
48 }
49
50 void Pop(T& out)
51 {
52 pthread_mutex_lock(&lock);
53 while(IsEmpty()){
54 //wait
55 pthread_cond_wait(&empty, &lock);
56 }
57 out = q.front();
58 q.pop();
59
60 pthread_mutex_unlock(&lock);
61 pthread_cond_signal(&full);
62 }
63 ~BlockQueue()
64 {
65 pthread_mutex_destroy(&lock);
66 pthread_cond_destroy(&full);
67 pthread_cond_destroy(&empty);
68 }
69
70 private:
71 std::queue<T> q;//临界资源
72 int _cap;
73 pthread_mutex_t lock;
74 pthread_cond_t full;
75 pthread_cond_t empty;
76 };
main.cc
1 #include "BlockQueue.hpp"
2
3 void* Product(void* arg)
4 {
5 auto bq = (BlockQueue<int>*)arg;
6 while(true){
7 int data = rand()%100 + 1;
8 bq->Push(data);//生产数据就
9 std::cout << "prducter: " << data << std::endl;
10 sleep(1);
11 }
12 }
13
14 void* Consumer(void* arg)
15 {
16 auto bq = (BlockQueue<int>*)arg;
17 while(true){
18 int data = 0;
19 bq->Pop(data);
20 std::cout << "consumer: " << data << std::endl;
21 sleep(1);
22 }
23 }
24
25
26 int main()
27 {
28 srand((unsigned long)time(nullptr));
29 BlockQueue<int>* bq = new BlockQueue<int>();
30
31 pthread_t c, p;
32 pthread_create(&c, nullptr, Consumer, bq);
33 pthread_create(&p, nullptr, Product, bq);
34
35 pthread_join(c, nullptr);
36 pthread_join(p, nullptr);
37
38 return 0;
39 }
Makefile
1 cp:main.cc
2 g++ -o $@ $^ -std=c++11 -lpthread
3 .PHONY:clean
4 clean:
5 rm -f cp
设置的这个是生产和消费的步调是一致的,所以看到的就是生产一个消费一个,生产一个消费一个。
那么接下来看看生产的很快,而消费的很慢:
我们可以看到生产满,然后每消费一个就生产一个,然后就这样进行下去。
当然也可以让消费者快一点:
我们看到的也就是生产出来,很快被消费,也就是生产一条消费一条,如此往复。
我们现在设置的是每生产一个就通知消费者可以消费了,消费者每消费一个,就通知生产者可以生产了。
当然我们也可以设置成当产品低于一定的数量就开始生产,当产品高于一定数量就开始消费:
生产的快,消费的慢,我们可以看到,先生产一部分,然后通知消费者可以消费了,但因为还没有生产满,所以还是继续生产。(这里也是因为虽然都满足了生产和消费的条件,但是生产的很快,消费竞争不过生产,所以这样)
生产满了之后消费者开始消费,消费到一定程度就又继续开始生产。
如果成产的很慢,而消费的很快就会是下面的效果:
那么这里还想说的是 pthread_cond_wait() 是让当前执行流进行等待的函数,只要是函数就有可能被调用失败,或者被伪唤醒。
但是要是失败的时候就会出现问题:
先说生产,如果已经满了,然后 pthread_cond_wait() 失败了,那么还会再次进行 Push,但是队列已经满了,这时就会出现问题。
对于消费更严重,现在队列为空不能消费,但是 pthread_cond_wait() 失败了,但是还会执行 Pop ,又因为队列就是空的,此时就会出现问题。
而且我现在设计的是单生产者单消费者,那么有没有可能是单生产者多消费者呢?
比如果生产者刚刚向队列里生产了一个数据,但是这个数据是被多个线程竞争的,很多个线程因为竞争不过而到条件队列下等。当生产完一个后,给消费发消息说可以消费了,但是因为 pthread_cond_wait() 失败,就可能导致多个线程向下进行运行,但是此时就一个数据,多个线程同时消费就会出现问题!
所以 pthread_cond_wait() 判断这里不要用 if ,而是要用 while :
这样的好处是,当任何一个线程被唤醒时,它并不着急向后执行,而且重新再检测一次自己等待的条件是否还满足,如果它是被伪唤醒或者是调用失败就会继续等待,只有这个(while)条件真的已经不满足了,才会继续向下执行。
那么生产者者应该是生产任务,而消费者应该是消费任务,那么现在就来创建一个任务来让他们完成:
main.cc
1 #include "BlockQueue.hpp"
2 #include "Task.cpp"
3
4 void* Product(void* arg)
5 {
6 auto bq = (BlockQueue<Task>*)arg;
7 const char* arr = "+-*/%";
8 while(true){
9 //sleep(1);
10 int x = rand()%100 + 1;
11 int y = rand()%50;
12 char op = arr[rand()%5];
13 Task t(x, y, op);
14 bq->Push(t);//生产数据就
15 std::cout << "prducter task done! " <<std::endl;
16 }
17 }
18
19 void* Consumer(void* arg)
20 {
21 auto bq = (BlockQueue<Task>*)arg;
22 while(true){
23 sleep(1);
24 Task t;
25 bq->Pop(t);
26 t.Run();
27 }
28 }
29
30
31 int main()
32 {
33 srand((unsigned long)time(nullptr));
34 BlockQueue<Task>* bq = new BlockQueue<Task>();
35
36 pthread_t c, p;
37 pthread_create(&c, nullptr, Consumer, bq);
38 pthread_create(&p, nullptr, Product, bq);
39
40 pthread_join(c, nullptr);
41 pthread_join(p, nullptr);
42
43 return 0;
44 }
BlockQueue.hpp
1 #pragma once
2
3 #include <iostream>
4 #include <ctime>
5 #include <queue>
6 #include <cstdlib>
7 #include <pthread.h>
8 #include <unistd.h>
9
10 #define NUM 8
11
12 template<typename T>
13 class BlockQueue
14 {
15 private:
16 bool IsFull()
17 {
18 return q.size() == _cap;
19 }
20
21 bool IsEmpty()
22 {
23 return q.empty();
24 }
25 public:
26 BlockQueue(int cap = NUM)
27 :_cap(cap)
28 {
29 pthread_mutex_init(&lock, nullptr);
30 pthread_cond_init(&full, nullptr);
31 pthread_cond_init(&empty, nullptr);
32 }
33
34 void Push(const T& in)
35 {
36 pthread_mutex_lock(&lock);
37 while(IsFull()){
38 //wait
39 // pthread_cond_wait 两个功能
40 // 一个功能是在这个条件变量上等待
41 // 第二个功能是释放这个互斥锁
42 // 如果当前等待线程被唤醒,会自动获得对应的mutex
43 pthread_cond_wait(&full, &lock);
44 }
45 q.push(in);
46 if(q.size() >= _cap/2){
47 std::cout << "数据已经很多了,消费者快来消费吧!" << std::endl;
48 pthread_cond_signal(&empty);
49 }
50 pthread_mutex_unlock(&lock);
51 }
52
53 void Pop(T& out)
54 {
55 pthread_mutex_lock(&lock);
56 while(IsEmpty()){
57 //wait
58 pthread_cond_wait(&empty, &lock);
59 }
60 out = q.front();
61 q.pop();
62 if(q.size() <= _cap/2){
63 std::cout << "空间已经很多了,生产者快来生产吧!" << std::endl;
64 pthread_cond_signal(&full);
65 }
66 pthread_mutex_unlock(&lock);
67 }
68 ~BlockQueue()
69 {
70 pthread_mutex_destroy(&lock);
71 pthread_cond_destroy(&full);
72 pthread_cond_destroy(&empty);
73 }
74
75 private:
76 std::queue<T> q;//临界资源
77 int _cap;
78 pthread_mutex_t lock;
79 pthread_cond_t full;
80 pthread_cond_t empty;
81 };
Task.hpp
1 #pragma once
2
3 #include <iostream>
4
5 class Task{
6 private:
7 int _x;
8 int _y;
9 char _op;
10 public:
11 Task(int x, int y, char op)
12 :_x(x)
13 ,_y(y)
14 ,_op(op)
15 {}
16 Task()
17 {}
18 void Run()
19 {
20 int ret = 0;
21 switch(_op){
22 case '+':
23 ret = _x + _y;
24 break;
25 case '-':
26 ret = _x - _y;
27 break;
28 case '*':
29 ret = _x * _y;
30 break;
31 case '/':
32 if(_y == 0){
33 std::cout << "Warning: div zero!" << std::endl;
34 ret = -1;
35 }
36 else{
37 ret = _x / _y;
38 }
39 break;
40 case '%':
41 ret = _x % _y;
42 break;
43 default:
44 break;
45 }
46 std::cout << _x << _op << _y << "=" << ret << std::endl;
47 }
48
49 ~Task()
50 {}
51 };
我们可以看到,生产者生产了一堆数据,然后消费者负责运行。
这就像是从客户端发送过来很多下单请求,然后将这些下单请求放到任务队列里,然后消费者不断处理这些下单请求,就可以完成简单的基于任务的生产者消费者模型。
9. POSIX信号量
POSIX 信号量和 SystemV 信号量作用相同,都是用于同步操作,达到无冲突的访问共享资源目的。 但POSIX可以用于线程间同步。
初始化信号量:
#include <semaphore.h>
int sem_init(sem_t *sem, int pshared, unsigned int value);
参数:
pshared:0表示线程间共享,非零表示进程间共享
value:信号量初始值
销毁信号量:
int sem_destroy(sem_t *sem);
等待信号量:
功能:等待信号量,会将信号量的值减1
int sem_wait(sem_t *sem);
发布信号量:
功能:发布信号量,表示资源使用完毕,可以归还资源了。将信号量值加1。
int sem_post(sem_t *sem);
信号量是一个计数器,如果 sem 值是 1 ,基本等价于互斥锁,也就是二元信号量。
1 #include <iostream>
2 #include <semaphore.h>
3 #include <pthread.h>
4 #include <string>
5 #include <unistd.h>
6
7 class Sem{
8 private:
9 sem_t sem;
10 public:
11 Sem(int num)
12 {
13 sem_init(&sem, 0, num);
14 }
15
16 void P()
17 {
18 sem_wait(&sem);
19 }
20 void V()
21 {
22 sem_post(&sem);
23 }
24
25 ~Sem()
26 {
27 sem_destroy(&sem);
28 }
29
30 };
31
32 Sem sem(1);
33 int tickets = 2000;
34 void* GetTickets(void* arg)
35 {
36 std::string name = (char*)arg;
37 while(true){
38 sem.P();
39 if(tickets > 0){
40 usleep(1000);
41 std::cout << name << " get ticket: " << tickets-- << std::endl;
42 sem.V();
43 }
44 else{
45 sem.V();
46 break;
47 }
48 }
49
50 std:: cout << name << " quit" << std::endl;
51
52 pthread_exit((void*)0);
53 }
54
55 int main()
56 {
57 pthread_t tid1, tid2, tid3;
58 pthread_create(&tid1, nullptr, GetTickets, (void*)"thread 1");
59 pthread_create(&tid2, nullptr, GetTickets, (void*)"thread 2");
60 pthread_create(&tid3, nullptr, GetTickets, (void*)"thread 3");
61
62 pthread_join(tid1, nullptr);
63 pthread_join(tid2, nullptr);
64 pthread_join(tid3, nullptr);
65
66 return 0;
67 }
我们可以看到,虽然一个线程连续抢了多个票,但是还是又切换的。在来说不可能有线程有强规律的去抢票,除非用同步。
这是因为在申请信号量的时候,自己的线程执行完后,还会继续参与信号量的竞争,那么被唤醒的线程和我竞争的时候,被唤醒的失败了,那么我就继续执行,然后可能就被排到了队列的尾部,那么就可能会看到,一个线程刚被唤醒就被挂起了,又被唤醒了,又被挂起了。
结论就是我们可以通过使用二元信号量来实现互斥功能!
基于环形队列的生产消费模型
生产者关心空间。
消费者关心数据。
而且刚开始的空位是满的,数据为 0 。
它们需要遵守的原则:
- 生产和消费不能指向同一个位置
- 无论是生产者还是消费者,都不应该将对方套一圈以上
Ring.hpp
1 #pragma once
2
3 #include <iostream>
4 #include <vector>
5 #include <semaphore.h>
6 #include <pthread.h>
7 #include <ctime>
8 #include <stdlib.h>
9 #include <unistd.h>
10
11 #define NUM 8
12
13 template<typename T>
14 class RingQueue{
15 private:
16 std::vector<T> q;
17 int cap;
18 int c_pos;
19 int p_pos;
20
21 sem_t blank_sem;
22 sem_t data_sem;
23 private:
24 void P(sem_t& s)
25 {
26 sem_wait(&s);
27 }
28 void V(sem_t& s)
29 {
30 sem_post(&s);
31 }
32 public:
33 RingQueue(int _cap = NUM)
34 :cap(_cap)
35 ,c_pos(0)
36 ,p_pos(0)
37 {
38 q.resize(cap);
39 sem_init(&blank_sem, 0, cap);
40 sem_init(&data_sem, 0, 0);
41 }
42
43 void Push(const T& in)
44 {
45 P(blank_sem);
46 q[p_pos] = in;
47 V(data_sem);
48
49 p_pos++;
50 p_pos %= cap;
51 }
52 void Pop(T& out)
53 {
54 P(data_sem);
55 out = q[c_pos];
56 V(blank_sem);
57
58 c_pos++;
59 c_pos %= cap;
60 }
61
62 ~RingQueue()
63 {
64 sem_destroy(&blank_sem);
65 sem_destroy(&data_sem);
66 }
67 };
main.cc
1 #include "Ring.hpp"
2
3
4 void* Product(void* arg)
5 {
6 RingQueue<int>* rq = (RingQueue<int>*)arg;
7
8 while(true){
9 int x = rand()%100 + 1;
10 rq->Push(x);
11 std::cout << "product done >>> " << x << std::endl;
12 }
13 }
14
15 void* Consumer(void* arg)
16 {
17 RingQueue<int>* rq = (RingQueue<int>*)arg;
18
19 while(true){
20 sleep(1);
21 int x = 0;
22 rq->Pop(x);
23 std::cout << "consume done <<<" << x << std::endl;
24 }
25 }
26
27 int main()
28 {
29 srand((unsigned long)time(nullptr));
30
31 RingQueue<int>* rq = new RingQueue<int>();
32 pthread_t c, p;
33 pthread_create(&c, nullptr, Consumer, rq);
34 pthread_create(&p, nullptr, Product, rq);
35
36
37
38 pthread_join(c, nullptr);
39 pthread_join(p, nullptr);
40 return 0;
41 }
当生产比消费快,我们可以看到这个现象。
那么这里有没有可能有数据不一致的问题呢?
绝对不可能!因为只有两种情况指向同一位置,只有指向了同一位置才会有临界资源竞争的问题。
但是这两种情况一个是在环形队列为空的时候 和 环形队列满的时候,但是为空的时候消费者不能进行消费,为满的时候生产者不能生产,所以也不会有问题!
10. 线程池
/*threadpool.h*/
/* 线程池: *
一种线程使用模式。线程过多会带来调度开销,进而影响缓存局部性和整体性能。而线程池维护着多个线程,等待着监督管理者分配可并发执行的任务。这避免了在处理短时间任务时创建与销毁线程的代价。线程池不仅能够保证内核的充分利用,还能防止过分调度。可用线程数量应该取决于可用的并发处理器、处理器内核、内存、网络sockets等的数量。
* 线程池的应用场景:
- 需要大量的线程来完成任务,且完成任务的时间比较短。WEB服务器完成网页请求这样的任务,使用线程池技术是非常合适的。因为单个任务小,而任务数量巨大,你可以想象一个热门网站的点击次数。但对于 长时间的任务,比如一个Telnet连接请求,线程池的优点就不明显了。因为Telnet会话时间比线程的创建时间大多了。
- 对性能要求苛刻的应用,比如要求服务器迅速响应客户请求。
- 接受突发性的大量请求,但不至于使服务器因此产生大量线程的应用。突发性大量客户请求,在没有线程池情况下,将产生大量线程,虽然理论上大部分操作系统线程数目最大值不是问题,短时间内产生大量线程可能使内存到达极限,出现错误。
* 线程池的种类:
* 线程池示例:
* 1. 创建固定数量线程池,循环从任务队列中获取任务对象,
* 2. 获取到任务对象后,执行任务对象中的任务接口
*/
main.cc
1 #include "ThreadPool.hpp"
2 #include "Task.hpp"
3 #include <cstdlib>
4 #include <ctime>
5 #include <unistd.h>
6
7 int main()
8 {
9 ThreadPool<Task>* tp = new ThreadPool<Task>();
10 tp->InitThreadPool();
11
12 srand((unsigned long)time(nullptr));
13 const char* op = "+-*/%";
14 while(true){
15 int x = rand()%100 + 1;
16 int y = rand()%50 + 1;
17 Task t(x, y, op[rand()%5]);
18
19 tp->Push(t);
20
21 sleep(1);
22 }
23
24 return 0;
25 }
Task.hpp
1 #pragma once
2
3 #include <iostream>
4 #include <pthread.h>
5
6 //Run那里可以用回调函数
7 //typedef int (*handler_t)(int, int, char);
8
9 class Task{
10 private:
11 int x;
12 int y;
13 char op;
14 public:
15 Task(int _x, int _y, char _op)
16 :x(_x)
17 ,y(_y)
18 ,op(_op)
19 {}
20 Task()
21 {}
22 void Run()
23 {
24 int ret = 0;
25 switch(op){
26 case '+':
27 ret = x + y;
28 break;
29 case '-':
30 ret = x - y;
31 break;
32 case '*':
33 ret = x * y;
34 break;
35 case '/':
36 if(y == 0){
37 std::cerr << "div zero!" << std::endl;
38 ret = -1;
39 }
40 else{
41 ret = x / y;
42 }
43 break;
44 case '%':
45 if(y == 0){
46 std::cerr << "div zero" << std::endl;
47 }
48 else{
49 ret = x % y;
50 }
51 break;
52 default:
53 std::cerr << "operator error!" << std::endl;
54 break;
55 }
56 std::cout << "thread: [" << pthread_self() << "]" << x << op << y << "=" << ret << std::endl;
57 }
58
59 ~Task()
60 {}
61 };
ThreadPool.hpp
1 #pragma once
2
3 #include <iostream>
4 #include <queue>
5 #include <pthread.h>
6
7 #define NUM 5
8
9 template<typename T>
10 class ThreadPool{
11 private:
12 int thread_num;
13 std::queue<T> task_queue;
14 pthread_mutex_t lock;
15 pthread_cond_t cond;
16
17 public:
18 ThreadPool(int _num = NUM)
19 :thread_num(_num)
20 {
21 pthread_mutex_init(&lock, nullptr);
22 pthread_cond_init(&cond, nullptr);
23 }
24
25 //因为static不能直接访问private
26 //所以要对其进行封装
27 void LockQueue()
28 {
29 pthread_mutex_lock(&lock);
30 }
31 void UnlockQueue()
32 {
33 pthread_mutex_unlock(&lock);
34 }
35 bool IsEmpty()
36 {
37 return task_queue.empty();
38 }
39 void Wait()
40 {
41 pthread_cond_wait(&cond, &lock);
42 }
43 void Wakeup()
44 {
45 pthread_cond_signal(&cond);
46 }
47 // 这里要用static是因为在类里写成员函数时会带上
48 // this指针,但是这个参数只能传一个void*类型的参数
49 static void* Routine(void* arg)
50 {
51 pthread_detach(pthread_self());
52 ThreadPool* self = (ThreadPool*)arg;
53 while(true){
54 self->LockQueue();
55 while(self->IsEmpty()){
55 while(self->IsEmpty()){
56 //wait
57 self->Wait();
58 }
59 //任务
60 T t;
61 self->Pop(t);
62 self->UnlockQueue();
63 //因为执行任务的时候,这个任务属于你当前线程
64 //所以接下来只需要在Unlock之后处理就可以了
65 //如果在里面处理,那其它线程要等你处理完才可以处理下一个
66 //如果这样,虽然是线程池,那也没有让多个线程同时跑起来
67 t.Run();
68 }
69 }
70 void InitThreadPool()
71 {
72 pthread_t tid;
73 for(int i = 0; i < thread_num; i++){
74 pthread_create(&tid, nullptr, Routine, this);//所以这里要传this
75 }
76 }
77
78 void Push(const T& in)
79 {
80 LockQueue();
81 task_queue.push(in);
82 UnlockQueue();
83 //插入数据后其它线程的不知道
84 //所以要唤醒其它线程
85 Wakeup();
86 }
87
88 void Pop(T& out)
89 {
90 out = task_queue.front();
91 task_queue.pop();
92 }
93
94 ~ThreadPool()
95 {
96 pthread_mutex_destroy(&lock);
97 pthread_cond_destroy(&cond);
98 }
99 };
我们可以看到,我们完成了让每个线程批量化的去处理计算任务。而且我们也可以看到呈现出了很强的队列性。
11. 线程安全的单例模式
11.1 什么是单例模式
单例模式是一种 "经典的,常用的,常考的" 设计模式。
11.2 什么是设计模式
IT行业这么火,涌入的人很多。俗话说林子大了啥鸟都有。大佬和菜鸡们两极分化的越来越严重。为了让菜鸡们不太拖大佬的后腿,于是大佬们针对一些经典的常见的场景,给定了一些对应的解决方案,这个就是设计模式。
11.3 单例模式的特点
某些类,只应该具有一个对象(实例),就称之为单例。
例如一个男人只能有一个媳妇。
在很多服务器开发场景中,经常需要让服务器加载很多的数据 (上百G) 到内存中。此时往往要用一个单例的类来管理这些数据。
11.4 饿汉实现方式和懒汉实现方式
例子:
吃完饭,立刻洗碗,这种就是饿汉方式。因为下一顿吃的时候可以立刻拿着碗就能吃饭。
吃完饭,先把碗放下,然后下一顿饭用到这个碗了再洗碗,就是懒汉方式。
懒汉方式最核心的思想是 "延时加载". 从而能够优化服务器的启动速度.
12. STL、智能指针和线程安全
STL中的容器是否是线程安全的?
不是。
原因是,STL 的设计初衷是将性能挖掘到极致,而一旦涉及到加锁保证线程安全,会对性能造成巨大的影响。
而且对于不同的容器,加锁方式的不同,性能可能也不同(例如hash表的锁表和锁桶)。
因此 STL 默认不是线程安全。如果需要在多线程环境下使用,往往需要调用者自行保证线程安全。
智能指针是否是线程安全的?
对于 unique_ptr,由于只是在当前代码块范围内生效,因此不涉及线程安全问题。
对于 shared_ptr,多个对象需要共用一个引用计数变量,所以会存在线程安全问题。但是标准库实现的时候考虑到了这个问题,基于原子操作(CAS)的方式保证 shared_ptr 能够高效,原子的操作引用计数。
13. 其他常见的各种锁
- 悲观锁:在每次取数据时,总是担心数据会被其他线程修改,所以会在取数据前先加锁(读锁,写锁,行锁等),当其他线程想要访问数据时,被阻塞挂起。
- 乐观锁:每次取数据时候,总是乐观的认为数据不会被其他线程修改,因此不上锁。但是在更新数据前,会判断其他数据在更新前有没有对数据进行修改。主要采用两种方式:版本号机制和CAS操作。
- CAS操作:当需要更新数据时,判断当前内存值和之前取得的值是否相等。如果相等则用新值更新。若不等则失败,失败则重试,一般是一个自旋的过程,即不断重试。
- 自旋锁,公平锁,非公平锁 ... ...
我们现在用的锁基本都是悲观锁。
乐观锁在只读的场景下可以使用。
CAS是 JAVA 上的概念。
下面主要说说自旋锁:
挂起等待锁:自己先挂起,当资源就绪时再被唤醒。
自旋锁:不断的循环检测状态。
要选择挂起等待还是自旋,取决于已经拿到锁的线程,在执行临界区的时候,要占用多长时间!
但是要占用多少时间线程是不知道的,所以要使用哪个是要由程序员来判断的。
就像我们刚才实现的运算器,肯定是使用自旋锁能好啦~
自选锁的函数跟挂起等待的十分相似,所以直接将 mutex 换成 spin 就好了。
14. 读者写者问题
- 三种关系:读者和读者(没有关系)、写者和写者(互斥关系)、读者和写者(互斥关系、同步关系)。
- 两类角色:读者、写者。
- 一个:交易场所。
这里的读者和读者之间的关系和生产消费那里的消费是有本质的区别的。
消费是取走数据,而读是对数据进行拷贝。所以不存在竞争的关系。
读者写者问题的场景更多应用于:写数据的人写完之后,剩下的操作就是读取。还有就是写入操作少,而读取多。
实际中就像登录和注册,我们登录了一个网站,输入完账号和密码后,剩下的就是自己读取数据了。
接下来见到的接口肯定是和角色强相关的:
- 读者按照读的方式进行加锁。
- 写者按照写入的方式进行加锁。
在多线程情况下:
读写同时到来,如果还有人在读,接下来的读的角色不要进入临界区进行读,等写者进入,写完再来读------写者优先。
反之就是读者优先。
因为代码写起来不难,但是观察到的现象很不明显,所以接下来写一份伪代码供了解:
但其实在判断 count 不大于 0 的时候之后就解锁了,那么就有可能在写入的时候又有读者进来读了,就会造成数据不一致,所以理应是这样的:
而且上面就不用加锁了,但是上面更容易理解~
如上就是 多线程 的所有知识,下面要总结 网络基础 了,如果大家喜欢看此文章并且有收获,可以支持下 兔7 ,给 兔7 三连加关注,你的关注是对我最大的鼓励,也是我的创作动力~!
再次感谢大家观看,感谢大家支持!