千呼万唤始出来,终于到多线程方面的学习了!
所用系统Centos7.6
本文的源码👉【传送门】
[TOC]
1.线程的概念 在之前的linux学习中,已经接触过了进程的概念,进程由一个task_struct
结构体在操作系统中进行描述,CPU在执行的时候,会依照进程时间片进行轮询调度,让每一个进程的代码都得以推进,实现多个进程的同时运行
而线程,可以理解为是一种轻量化的进程,每一个进程都可以创建多个线程,并行执行不同的代码
在之前的多进程操作中,我们使用fork
接口创建子进程,通过if/else
语句判断,实现对特定执行流的划分
创建子进程时,需要拷贝一份task_struct/mm_struct
并创建页表 当子进程修改了一部分变量,会发生写时拷贝 ,修改页表在物理内存上的映射 可以看到,当我们需要创建一个新进程的时候,操作系统需要做不少的工作
1.1 执行流 让我们康康执行流这一概念:
单执行流进程:内部只有一个执行流的进程 多执行流进程:内部有多个执行流的进程 进程=内核数据结构+代码和数据
,在内核视角中,进程是承担分配系统资源的基本实体
(进程的基座属性)
进程:向系统申请资源的基本单位(系统分配) 线程:系统调度的基本单位 1.2 线程创建时做了什么? 那线程的创建需要做什么呢?
不同操作系统的实现不同,一般用tcb
指代描述线程的结构体
在linux中,没有进程和线程在概念上的区分,其以执行流 为基础,线程只是简单的对task_strcut
进行了二次封装;线程是在进程内部运行的执行流
说人话:linux下的线程是用进程模拟 的 换句话:linux下的进程也是一种线程,但是其只有一个执行流 对于CPU而言,其看到的task_struct
都是一个执行流 而创建线程时也有说法,线程隶属于某一个进程下,并不是独立的子进程,所以不需要创建新的mm_struct
和页表映射,创建的效率高于子进程。只需要将task_struct
指向原有进程的mm_struct
和页表即可。
同样的,CPU在推行多线程操作的时候,无须执行pcb切换,就能实现单进程多个线程操作 的同时进行,执行效率变高!
线程是一种Light weight process 轻量级进程
,简称LWP
1.3 内核源码中的体现 在task_strcut
结构体中,有这么一个字段
1 2 struct thread_struct thread ;
转到定义,其内部都是一些寄存器 信息,用于标识这个线程的基本信息。这也是linux中没有单独 实现线程tcb的体现,而是用task_struct
来模拟的
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 struct thread_struct { struct desc_struct tls_array [GDT_ENTRY_TLS_ENTRIES ]; unsigned long sp0; unsigned long sp; #ifdef CONFIG_X86_32 unsigned long sysenter_cs; #else unsigned long usersp; unsigned short es; unsigned short ds; unsigned short fsindex; unsigned short gsindex; #endif #ifdef CONFIG_X86_32 unsigned long ip; #endif #ifdef CONFIG_X86_64 unsigned long fs; #endif unsigned long gs; unsigned long debugreg0; unsigned long debugreg1; unsigned long debugreg2; unsigned long debugreg3; unsigned long debugreg6; unsigned long debugreg7; unsigned long cr2; unsigned long trap_no; unsigned long error_code; union thread_xstate *xstate ; #ifdef CONFIG_X86_32 struct vm86_struct __user *vm86_info ; unsigned long screen_bitmap; unsigned long v86flags; unsigned long v86mask; unsigned long saved_sp0; unsigned int saved_fs; unsigned int saved_gs; #endif unsigned long *io_bitmap_ptr; unsigned long iopl; unsigned io_bitmap_max; unsigned long debugctlmsr; struct ds_context *ds_ctx ; };
1.4 线程的私有物 我们知道,一个进程是完全独立的。但是线程并不是,因为线程只是进程的一个执行流分支,它从进程继承了绝大部分属性(也可以理解为是共享的)
用户id和组id 进程id 进程工作目录 文件描述符表 信号的处理方式(如果进程有对某个信号进行自定义捕捉,那么线程会共用这个自定义捕捉) 和进程共用一个堆 但线程也会有自己的私有物 !
线程id 线程独立的寄存器(因为线程也需要执行代码,有上下文数据) 栈(线程运行函数时也需要压栈和出栈,必须独立否则执行流会出问题) errno(单独的报错信息) 信号屏蔽字(可以单独针对某个信号处理) 线程调度优先级 1.5 线程优缺点 1.5.1 缺点 线程是缺乏保护的(不具备进程的独立性 )这也被称为健壮性
;线程的健壮性低
当进程被停止的时候,其下线程也会被停止 当有一个线程出bug了,会让整个进程退出 多线程中的全局变量问题 线程缺乏访问控制,在一个线程中调用某些操作系统的接口会影响整个进程
debug多线程较麻烦
如果同一个进程所用线程太多,可能会无法充分利用cpu性能而造成性能损失
1.5.2 优点 开辟的消耗低于进程,占用的资源低于进程 切换线程无须切换页表等结构,速度快 等待慢IO设备时,进程可以继续执行其他操作;将部分IO操作重叠,能让进程同时等待多个IO操作 能充分利用处理器的可并行数量 2.基础函数 linux下提供了pthread
库来实现线程操作
2.1 pthread_create 人如其名,这个函数的作用是来创建新进程的
1 2 3 4 #include <pthread.h> int pthread_create (pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg) ;
第一个参数是一个输出型参数 ,为该线程的id 第二个参数是用于指定线程的属性,暂时设置为NULL
使用默认属性 第三个参数是让该进程执行的函数,这是一个函数指针,参数和返回值都为void*
第四个参数是传给第三个执行函数的参数 创建正常后返回0,否则返回错误码
注意,使用了pthread库后,需要在编译的时候指定链接,-lpthread
1 typedef unsigned long int pthread_t ;
创建线程后打印可以发现,线程id是一个非常大的值,并不像进程PID那么小
1 2 pthread_create 140689524995840 140689516603136
可以通过printf %x
的方式来减少打印长度
2.2 pthread_join 光是创建进程还不够,我们还需要对进程进行等待
1 2 3 #include <pthread.h> int pthread_join (pthread_t thread, void **retval) ;
这里第一个参数是线程的id,第二个参数是进程的退出状态
等待成功后返回0,否则返回错误码
join可以在线程退出后,释放线程的资源 同时获取线程对应的退出码 join还能保证是新创建的线程退出后,主线程才退出 2.2.1 基础的多线程操作 有了这两个,我们就能写一个简单的多线程操作了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 #include <iostream> #include <pthread.h> #include <unistd.h> #include <sys/types.h> using namespace std;void * func1 (void * arg) { while (1 ) { cout << "func1 thread:: " << (char *)arg << " :: " << getpid () << endl; sleep (1 ); } } void * func2 (void * arg) { while (1 ) { cout << "func2 thread:: " << (char *)arg << " :: " << getpid () << endl; sleep (1 ); } } int main () { pthread_t t1,t2; pthread_create (&t1,nullptr ,func1,(void *)"1" ); pthread_create (&t2,nullptr ,func2,(void *)"2" ); while (1 ) { cout << "this is main::" << getpid ()<<endl; sleep (1 ); } pthread_join (t1,nullptr ); pthread_join (t2,nullptr ); return 0 ; }
执行会发现,多线程操作成功启动,且打印的进程pid都是一样的,代表其隶属于同一个进程
我们可以用下面的语句来查看轻量级进程
可以看到,执行了程序之后,出现了3个PID
相同,LWP
不同的轻量级进程,这就代表我们的多线程操作成功了;
同时也能看到,在多线程操作时,谁先运行是不确定的。这是由系统调度随机决定的
2.2.2 C++的多线程操作 C++11也支持了多线程操作,其封装了操作系统的pthread接口,基本的操作很相似
1 2 3 4 5 6 7 8 9 10 11 12 13 14 void test2 () { thread t1 (func1,(char *)"test1" ) ; thread t2 (func2,(char *)"test2" ) ; while (1 ) { cout << "this is main:: " << getpid ()<<endl; sleep (1 ); } t1.join (); t2.join (); }
执行后的效果是一样的,C++的thread库还可以传入functional
封装的可调用函数,和lambda
表达式
2.3 线程退出 2.3.1 retval 1 int pthread_join (pthread_t thread, void **retval) ;
我们可以使用该函数的第二个参数来获取线程所执行方法的返回值。retval
是一个二级指针,是一个输出型参数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 #include <iostream> #include <pthread.h> #include <thread> #include <unistd.h> #include <sys/types.h> using namespace std;void * func1 (void * arg) { int a = 5 ; while (a--) { cout << "func1 thread:: " << (char *)arg << " :: " << getpid () << endl; sleep (1 ); } cout << "func1 exit" << endl; return (void *)100 ; } void * func2 (void * arg) { int a = 10 ; while (a--) { cout << "func2 thread:: " << (char *)arg << " :: " << getpid () << endl; sleep (1 ); } cout << "func2 exit" << endl; return (void *)10 ; } void test3 () { pthread_t t1,t2; pthread_create (&t1,nullptr ,func1,(void *)"1" ); pthread_create (&t2,nullptr ,func2,(void *)"2" ); int a = 15 ; while (a--) { cout << "this is main:: " << getpid ()<<endl; sleep (1 ); } void * r1; void * r2; pthread_join (t1,&r1); pthread_join (t2,&r2); sleep (2 ); cout << "retval 1 : " << (long long )r1 << endl; cout << "retval 2 : " << (long long )r2 << endl; } int main () { test3 (); return 0 ; }
可以看到,当两个线程退出之后,主函数中成功打印出了他们的返回值
注意,因为我们是将void*
的指针强转为int,如果在打印的时候强转为int
,会出现精度丢失 的报错,需要使用long long
来规避报错
1 2 3 4 5 6 7 [muxue@bt-7274:~/git/linux/code/22-12-15_pthread]$ make g++ test.cpp -o test -lpthread -std=c++11 .test.cpp: In function ‘void test3()’: test.cpp:88:35: error: cast from ‘void*’ to ‘int’ loses precision [-fpermissive] cout << "retval 1 : " << (int)r1 << endl; ^ make: *** [test] Error 1
2.3.2 pthread_exit 除了直接return,线程还可以调用pthread_exit
函数实现退出
1 2 3 #include <pthread.h> void pthread_exit (void *retval) ;
效果完全一样
1 2 pthread_exit((void *)10 );
注意,主线程main中调用该函数,并不会导致进程退出
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 void * func2 (void * arg) { int a = 10 ; while (a--) { cout << "func2 thread:: " << (char *)arg << " :: " << getpid () << " tid: " << syscall (SYS_gettid) << endl; sleep (1 ); } cout << "func2 exit" << endl; pthread_exit ((void *)10 ); } void test5 () { pthread_t t1,t2; pthread_create (&t1,nullptr ,func2,(void *)"1" ); pthread_create (&t2,nullptr ,func2,(void *)"2" ); sleep (1 ); pthread_detach (t1); pthread_detach (t2); sleep (1 ); } int main () { test5 (); pthread_exit (0 ); cout << "main exit" << endl; return 0 ; }
可以看到,主函数已经调用了pthread_exit
退出了,但是线程还在跑
1 2 3 4 5 6 7 8 [muxue@bt-7274:~/git/linux/code/22-12-15_pthread]$ ./test func2 thread:: 1 :: 9474 tid: 9475 func2 thread:: 2 :: 9474 tid: 9476 func2 thread:: 1 :: 9474 tid: 9475 func2 thread:: 2 :: 9474 tid: 9476 main exit func2 thread:: 1 :: 9474 tid: 9475 func2 thread:: 2 :: 9474 tid: 9476
2.3.3 ptrhead_cancel 除了上面俩种方式,我们还可以在main里面直接把某一个线程给关掉
1 2 3 #include <pthread.h> int pthread_cancel (pthread_t thread) ;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 void test3 () { pthread_t t1,t2; pthread_create (&t1,nullptr ,func1,(void *)"1" ); pthread_create (&t2,nullptr ,func2,(void *)"2" ); int a = 15 ; while (a--) { cout << "this is main:: " << getpid ()<<endl; sleep (1 ); if (a==11 ) { pthread_cancel (t1); pthread_cancel (t2); break ; } } void * r1; void * r2; pthread_join (t1,&r1); pthread_join (t2,&r2); sleep (2 ); cout << "retval 1 : " << (long long )r1 << endl; cout << "retval 2 : " << (long long )r2 << endl; }
被提前终止的进程,返回值都为-1
2.3.4 为什么进程退出不会向主进程发送信号? 要理清楚这个问题,还是需要深知一个概念:线程是进程中的一个执行流,它并不是一个独立的进程。
先来回顾一下进程退出的几种情况:
代码跑完,结果正确 代码跑完,结果有问题 代码出错了,异常 线程退出的情况也是这样,但线程如果因为某些异常退出,进程也会同步退出 !
1 2 3 4 [muxue@bt-7274:~/git/linux/code/22-12-15_pthread]$ ./test this is main:: 13845 Floating point exception [muxue@bt-7274:~/git/linux/code/22-12-15_pthread]$
由此可见,线程异常 = 进程异常
这里也就涉及到1.5.1
中提到的线程健壮性 问题,线程的异常会影响其他线程的运行,会导致进程整体异常退出。
所以在join
等待线程退出的时候,我们只需要考虑线程正常退出的情况;
异常退出的时候恐怕也等不了😂因为进程也挂了
2.3.5 exit 任何一个线程执行exit()
函数,都会导致整个进程退出
2.4 pthread_detach 等待是有性能损失的!默认创建的进程是joinable
,也就是可以被主线程进行pthread_join
等待的;
这个函数的作用是让主线程不管创建出来的子线程,也不用去等待它,相当于取消了它的joinable
属性;
就好比父进程不想管子进程的时候,将SIGCHLD
设置为SIG_IGN
1 2 3 #include <pthread.h> int pthread_detach (pthread_t thread) ;
一个线程是否应该等待,取决于是否需要获取该线程的返回值;如果无须获取返回值,则使用分离能提高运行效率
即便线程所运行的函数return是无效的,但我们可以用输出型参数来获取返回值
2.4.1 实操 使用也很简单,只需要指定线程的id就行了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 void test4 () { pthread_t t1,t2; pthread_create (&t1,nullptr ,func3,(void *)"1" ); pthread_create (&t2,nullptr ,func3,(void *)"2" ); while (1 ) { cout << "this is main - global: " << global << " - &global: " << &global << endl; sleep (1 ); } pthread_detach (t1); pthread_detach (t2); }
运行上也不会有什么区别,但是我们已无法获取到该线程的返回值
2.4.2 detach后join 但如果我们在detach之后又进行pthread_join
会发生什么呢?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 void * func3 (void * arg) { pthread_detach (pthread_self ()); int a = 7 ; while (a--) { printf ("func thread:%s - global:%d - &global:%p\n" ,(char *)arg,global,&global); global++; sleep (1 ); } cout << "func exit" << endl; return (void *)10 ; } void test4 () { pthread_t t1,t2; pthread_create (&t1,nullptr ,func3,(void *)"1" ); pthread_create (&t2,nullptr ,func3,(void *)"2" ); void * r1=nullptr ; void * r2=nullptr ; pthread_join (t1,&r1); pthread_join (t2,&r2); sleep (2 ); cout << "retval 1 : " << (long long )r1 << endl; cout << "retval 2 : " << (long long )r2 << endl; }
诶,这不还是获取到了返回值吗?这么说,他这个detach
岂不是没用?
1 2 3 4 5 6 7 8 9 10 [muxue@bt-7274:~/git/linux/code/22-12-15_pthread]$ ./test func thread:1 - global:103 - &global:0x7fb5648b06fc func thread:2 - global:103 - &global:0x7fb5640af6fc func thread:1 - global:104 - &global:0x7fb5648b06fc func thread:2 - global:104 - &global:0x7fb5640af6fc func exit func exit retval 1 : 10 retval 2 : 10 [muxue@bt-7274:~/git/linux/code/22-12-15_pthread]$
实际上,当我们create一个线程的时候,它会先去执行线程创建的相关代码,此时main又直接去执行后面的代码了;此时pthread_join
的调用是成功的,因为线程自己的detach
代码还没有被执行 !
而如果我们在create之后,等线程开始运行了在执行detach
,此时join就会失败
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 void test4 () { pthread_t t1,t2; pthread_create (&t1,nullptr ,func3,(void *)"1" ); pthread_create (&t2,nullptr ,func3,(void *)"2" ); sleep (2 ); pthread_detach (t1); pthread_detach (t2); sleep (1 ); void * r1=nullptr ; void * r2=nullptr ; int ret = pthread_join (t1,&r1); cout << ret << ":" << strerror (ret) << endl; ret = pthread_join (t2,&r2); cout << ret << ":" << strerror (ret) << endl; cout << "retval 1 : " << (long long )r1 << endl; cout << "retval 2 : " << (long long )r2 << endl; sleep (20 ); }
打印错误码也能看到,系统提示我们给join
传入了一个无效的参数,线程依旧在正常运行
1 2 3 4 5 6 7 8 9 10 11 [muxue@bt-7274 :~/git/linux/code/22 -12 -15 _pthread]$ ./test func thread:1 - global:101 - &global:0x7f2d439136fc func thread:2 - global:101 - &global:0x7f2d431126fc func thread:2 - global:102 - &global:0x7f2d431126fc func thread:1 - global:102 - &global:0x7f2d439136fc 22 :Invalid argument22 :Invalid argumentretval 1 : 0 retval 2 : 0 func thread:2 - global:103 - &global:0x7f2d431126fc func thread:1 - global:103 - &global:0x7f2d439136fc
所以正确的做法,应该是在主线程中分离线程 ,不要在线程自己的代码中执行detach,否则就会出现上面的分离失败的情况
2.4.3 线程分离后,主线程先退出 如果执行完毕pthread_detach
后,主线程提前退出了,会发生什么?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 void test5 () { pthread_t t1,t2; pthread_create (&t1,nullptr ,func3,(void *)"1" ); pthread_create (&t2,nullptr ,func3,(void *)"2" ); sleep (1 ); pthread_detach (t1); pthread_detach (t2); sleep (2 ); cout << "main exit" << endl; }
显而易见,线程也跟着一并退出了
1 2 3 4 5 6 7 8 9 [muxue@bt-7274:~/git/linux/code/22-12-15_pthread]$ ./test func thread:1 - global:100 - &global:0x7f01cd49a6fc func thread:2 - global:100 - &global:0x7f01ccc996fc func thread:2 - global:101 - &global:0x7f01ccc996fc func thread:1 - global:101 - &global:0x7f01cd49a6fc func thread:2 - global:102 - &global:0x7f01ccc996fc func thread:1 - global:102 - &global:0x7f01cd49a6fc main exit [muxue@bt-7274:~/git/linux/code/22-12-15_pthread]$
因为线程没有独立性 ,完全属于这个进程。不可能出现你家房子塌了,你自己的房间还在的情况😂
进程退出的时候,操作系统就回收了这个进程的程序地址空间,连资源都被释放了,线程就没有办法继续运行,自然就退出了。
所以,为了避免这种问题,一般我们分离线程的时候,都倾向于让主线程保持在后台运行(常驻内存的程序)
2.5 gettid/syscall 该函数是一个系统接口,但它并不能直接运行
1 2 3 4 5 6 7 8 NAME gettid - get thread identification SYNOPSIS #include <sys/types.h> pid_t gettid(void); Note: There is no glibc wrapper for this system call; see NOTES.
我们需要用syscall函数 来调用该接口,这也是第一次接触到syscall函数
1 2 3 4 #define _GNU_SOURCE #include <unistd.h> #include <sys/syscall.h> int syscall (int number, ...) ;
在syscall的man手册中,我们就能看到获取线程id相关的示例
1 2 3 4 5 6 7 8 9 10 11 12 13 #define _GNU_SOURCE #include <unistd.h> #include <sys/syscall.h> #include <sys/types.h> int main (int argc, char *argv[]) { pid_t tid; tid = syscall(SYS_gettid); tid = syscall(SYS_tgkill, getpid(), tid); }
用下面的代码进行测试
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 void * func2 (void * arg) { int a = 10 ; while (a--) { cout << "func2 thread:: " << (char *)arg << " :: " << getpid () << " tid: " << syscall (SYS_gettid) << endl; sleep (1 ); } cout << "func2 exit" << endl; pthread_exit ((void *)10 ); } void test1 () { pthread_t t1,t2; pthread_create (&t1,nullptr ,func2,(void *)"1" ); pthread_create (&t2,nullptr ,func2,(void *)"2" ); while (1 ) { printf ("tis is main - pid:%d - tid:%d\n" ,getpid (),syscall (SYS_gettid)); sleep (1 ); } pthread_join (t1,nullptr ); pthread_join (t2,nullptr ); }
运行可以看到进程打印出了相同的PID和不同的TID,其TID对应的就是ps -aL
中显示的LWP
编号
3.相关概念 3.1 线程id是什么? 前面提到过,pthread_t
是线程独立的id,本质上是一个无符号长整形,打印出来后,是一个很大的数字。这个数字有什么特别的含义吗?
先来回顾一下线程的基本概念:
线程是一个独立的执行流 线程在运行过程中,会产生自己的临时数据 线程调用函数的压栈出栈操作,有自己独立的栈结构 因此,既然有一个独立的栈结构,其就需要有一个标识符来指向这个栈结构,方便程序运行的时候进行调用!
所以,pthread_t
本质上是一个地址!其指向的就是这个线程的控制块 ,其内部包含了这个线程的独立栈结构。
3.2 pthread库 pthread库并不是一个内核级的接口库,其实际上是封装了系统的clone/vfork
等接口,从而为我们提供的用户级的线程库。
使用pthread库创建的进程,和内核中的LWP是1:1
的
pthread是一个动态库 ,所以在编译的时候需要加上链接选项
1 g++ test.cpp -o test -lpthread
在我的 动静态库 的博客中有讲述过,动态库是在运行的时候动态链接的,其会将库中的代码映射到进程地址空间的共享区
,从而调用动态库中的代码
举个例子,当我们调用pthead_create
的时候,进程会跳到共享区中 ,执行动态库中的代码,创建成功后返回自己的代码区
,完成一个线程的创建
而线程所用的独立栈,也是pthread库帮我们管理的。因为有共享区的存在,我们能通过pthread_t
直接访问到动态库中管理的线程的控制模块 ,从而完成线程的压栈、出栈等等操作
下为linux的pthreadtypes.h
中的部分内容
1 2 3 4 5 6 7 8 9 10 11 12 # define __SIZEOF_PTHREAD_ATTR_T 36 typedef unsigned long int pthread_t ;union pthread_attr_t { char __size[__SIZEOF_PTHREAD_ATTR_T]; long int __align; }; #ifndef __have_pthread_attr_t typedef union pthread_attr_t pthread_attr_t ;# define __have_pthread_attr_t 1 #endif
3.3 线程的局部存储 假设我们有一个全局变量,我们想让创建出来的每一个线程,都能独立的使用这个全局变量,那就需要用到线程的局部存储
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 int global = 10 ;void * func3 (void * arg) { int a = 10 ; while (a--) { cout << "func thread " << (char *)arg << " - global: " << global << " - &global: " << &global << endl; sleep (1 ); } cout << "func exit" << endl; } void test4 () { pthread_t t1,t2; pthread_create (&t1,nullptr ,func3,(void *)"1" ); pthread_create (&t2,nullptr ,func3,(void *)"2" ); while (1 ) { cout << "this is main - global: " << global << " - &global: " << &global << endl; sleep (1 ); } pthread_join (t1,nullptr ); pthread_join (t2,nullptr ); }
执行,不管是主线程还是线程,都打印的是相同的值和地址
如果在执行的函数func3
中添加一个global++
,则能观察到所有线程都是公用的一个变量,这里的+是同步的。
如果我们想让int global
变成局部变量,则需要在它之前加上一个__thread
1 __thread int global = 100 ;
此时可以看到,两个线程和主线程打印的global变量地址不同,他们的++
操作是独立的,变量的值也是独立的
这就实现了将某一个变量划分给线程进行局部存储
4.线程互斥问题 4.1 临界资源 在先前共享内存 信号量 的博客中,已经涉及到了这部分的内容;即关于操作原子性 和访问临界资源/临界区 的相关问题。
能被多个进程/线程看到的资源,被称为临界资源
进程/线程访问临界资源的代码,被称为临界区
在线程中,同样存在访问临界资源 而导致的冲突:
线程A对一个全局变量val 进行了-1
操作,当操作执行到放回内存那一步的时候,发生了线程切换,线程B开始工作 线程B同样访问了该全局变量val,对它进行了-10
操作,此时因为线程A的-1
操作尚未写回内存,全局变量val还是保持初值。线程b将-10
之后的全局变量val写回了内存 又发生了线程切换,跳转到线程A停止的线程上下文数据 中开始执行,将全局变量写入内存 这时候,线程B的-10
操作就被A的写入覆盖了! 举个实际点的例子,以100为全局变量的初始值
线程A执行-1,100-1=99
,还未写入内存时,就线程切换 线程B取到的全局变量还是100,对其执行-10,并写入内存, 此时全局变量为90 返回线程A继续执行写入内存操作,全局变量又被复写成了99;相当于B的操作是无效的 这种条件下会产生很多问题,也是我们不希望看到的!
4.2 原子/互斥性 这种时候,我们就需要保证访问该全局变量的操作是原子 的,不能出现中间状态;
也应该是互斥 的,不能出现两个线程同时访问一份资源的情况
互斥性:任何时候都只有一个执行流在访问某一份资源
为了达成这一目的,我们需要给线程的操作加锁
4.3 线程加锁 线程加锁涉及到几个操作:
提供一把锁 在需要维持原子性(临界区)的位置加上锁 访问临界区结束后,打开锁 进程结束后,把锁丢了 接下来就让我们一一解决这些问题
4.3.1 pthread_mutex_init pthread在设计之初就考虑到了这种问题,所以它便给我们提供了加锁相关的操作
1 2 3 4 5 6 #include <pthread.h> int pthread_mutex_destroy (pthread_mutex_t *mutex) ;int pthread_mutex_init (pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr) ;pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
首先我们需要定义一把锁,类型是pthread_mutex_t
如果我们需要的是一把全局变量 的锁,则可以直接使用PTHREAD_MUTEX_INITIALIZER
给这把锁初始化 如果是一把局部的锁,则使用函数pthread_mutex_init
进行初始化 初始化的方法很简单,传入锁和对应的属性就行。此时我们忽略属性问题,设置为NULL
使用默认属性
1 2 3 4 5 6 7 8 pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;pthread_mutex_t mutex;pthread_mutex_init(&mutex, nullptr); pthread_mutex_destroy(&mutex);
4.3.2 加锁/解锁 有了锁,那么就可以在需要的位置加上这把锁
1 2 3 4 5 #include <pthread.h> int pthread_mutex_lock (pthread_mutex_t *mutex) ;int pthread_mutex_trylock (pthread_mutex_t *mutex) ;int pthread_mutex_unlock (pthread_mutex_t *mutex) ;
其中lock是阻塞式 加锁,如果你调用这个接口的时候,锁正在被别人使用,则会在这里等待;trylock是非阻塞 加锁,如果你调用该接口时锁正被使用,则直接return
返回
1 The pthread_mutex_trylock() function shall be equivalent to pthread_mutex_lock(), except that if the mutex object referenced by mutex is currently locked (by any thread, including the current thread), the call shall return immediately.
加了锁之后,在需要的位置unlock
解锁;
加锁和解锁操作本身 是原子的,不会出现冲突 加了锁之后,可以理解为加锁解锁操作中间 的代码也是原子性的,必须要运行到解锁位置 才能让另外一个线程/进程执行这里的代码 加锁的本质是让线程执行临界区的代码串行化 4.3.3 加锁的注意事项 只对临界区加锁;锁保护的就是临界区 加锁的粒度越细越好(即加锁的区域越小越好) 加锁是编程的一种规范;在实际问题中,我们要保证访问某一临界资源的所有操作都要加上锁。不能出现函数A加锁了,但是B没有加锁的情况(这样会导致A的加锁也没有意义) 4.4 示例-倒水问题
以倒水 为示例,假设杯子容量为10000,装满了水就会溢出 。我们使用多个线程对这个杯子加水,直到满了之后线程退出
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 #include <iostream> #include <string.h> #include <signal.h> #include <pthread.h> #include <thread> #include <unistd.h> #include <sys/types.h> #include <sys/syscall.h> using namespace std;int water = 0 ;int cup = 10000 ;void * func (void * arg) { while (1 ) { if (water<cup) { cout << (char *)arg << " 水没有满:" << water << "\n" ; water++; } else { cout << (char *)arg << " 水已经满了 " << water << "\n" ; break ; } } cout << (char *)arg << " 线程退出" << "\n" ; return (void *)0 ; } int main () { pthread_t t1,t2,t3,t4; pthread_create (&t1,nullptr ,func,(void *)"t1" ); pthread_create (&t2,nullptr ,func,(void *)"t2" ); pthread_create (&t3,nullptr ,func,(void *)"t3" ); pthread_create (&t4,nullptr ,func,(void *)"t4" ); pthread_detach (t1); pthread_detach (t2); pthread_detach (t3); pthread_detach (t4); while (1 ) { ; } return 0 ; }
输出的结果如下,明明水已经满了,但还是会有部分线程报告水还没有满,且数字有很严重的偏差
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 t3 水没有满:9993 t3 水没有满:9994 t3 水没有满:9995 t3 水没有满:9996 t3 水没有满:9997 t3 水没有满:9998 t3 水没有满:9999 t3 水已经满了 t3 线程退出 水没有满:2723 t4 水已经满了 t4 线程退出 0 t2 水已经满了 t2 线程退出 t1 水没有满:9668 t1 水已经满了 t1 线程退出
多运行几次,也能发现相同的问题
1 2 3 4 5 6 7 8 9 10 11 12 13 14 t2 水没有满:9997 t2 水没有满:9998 t2 水没有满:9999 t2 水已经满了 10000 t2 线程退出 t4 水没有满:1889 t4 水已经满了 10001 t4 线程退出 t3 水没有满:0 t3 水已经满了 10002 t3 线程退出 t1 水没有满:0 t1 水已经满了 10003 t1 线程退出
4.4.1 只有一个线程在工作? 除了偏差外,还有一个小问题,往前翻打印记录,会发现一直都是某一个线程在倒水 ,其他线程似乎啥事没有干?
1 2 3 4 5 t3 水没有满:9786 t3 水没有满:9787 t3 水没有满:9788 t3 水没有满:9789 t3 水没有满:9790
这是因为当运行t3的时候,t3在while循环中继续运行的消耗,小于切换到其他线程的消耗 。所以控制块就让t3一直运行,直到它break退出循环
此时我们只需要加上一个usleep,增加每一个while循环中需要处理的负担 ,就能让所有线程都来倒水
1 2 3 #include <unistd.h> int usleep (useconds_t usec) ;
这是因为线程切换同样也是时间片到了,从内核返回用户态的时候做检测,切换至其他线程。
添加usleep能创造更多内核/用户的中间态 ,从而增多切换线程的次数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 void * func (void * arg) { while (1 ) { if (water<cup) { usleep (100 ); cout << (char *)arg << " 水没有满:" << water << "\n" ; water++; } else { cout << (char *)arg << " 水已经满了" << "\n" ; break ; } } cout << (char *)arg << " 线程退出" << "\n" ; return (void *)0 ; }
但是这还是没有解决数字出错的问题
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 t4 水没有满:9995 t3 水没有满:9996 t1 水没有满:9997 t2 水没有满:9998 t4 水没有满:9999 t4 水已经满了 10000 t4 线程退出 t3 水没有满:10000 t3 水已经满了 10001 t3 线程退出 t1 水没有满:10001 t1 水已经满了 10002 t1 线程退出 t2 水没有满:10002 t2 水已经满了 10003 t2 线程退出
4.4.2 加锁-问题解决 这时候就需要请出我们的锁了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 int water = 0 ;int cup = 10000 ;pthread_mutex_t mutex;void * func (void * arg) { while (1 ) { pthread_mutex_lock (&mutex); if (water<cup) { usleep (100 ); cout << (char *)arg << " 水没有满:" << water << "\n" ; water++; pthread_mutex_unlock (&mutex); usleep (100 ); } else { cout << (char *)arg << " 水已经满了 " << water << "\n" ; pthread_mutex_unlock (&mutex); break ; } } cout << (char *)arg << " 线程退出" << "\n" ; return (void *)0 ; } void des (int signo) { pthread_mutex_destroy (&mutex); cout << "pthread_mutex_destroy, exit" << endl; exit (0 ); } int main () { signal (SIGINT,des); pthread_mutex_init (&mutex,nullptr ); pthread_t t1,t2,t3,t4; pthread_create (&t1,nullptr ,func,(void *)"t1" ); pthread_create (&t2,nullptr ,func,(void *)"t2" ); pthread_create (&t3,nullptr ,func,(void *)"t3" ); pthread_create (&t4,nullptr ,func,(void *)"t4" ); pthread_detach (t1); pthread_detach (t2); pthread_detach (t3); pthread_detach (t4); while (1 ) { ; } return 0 ; }
运行可见,数字错误问题就没有出现了;但又出现了只有一个线程工作的问题
1 2 3 4 5 6 7 8 9 10 11 12 13 t1 水没有满:9996 t1 水没有满:9997 t1 水没有满:9998 t1 水没有满:9999 t1 水已经满了 10000 t1 线程退出 t3 水已经满了 10000 t3 线程退出 t4 水已经满了 10000 t4 线程退出 t2 水已经满了 10000 t2 线程退出 ^Cpthread_mutex_destroy, exit
这还是因为线程切换的效率问题;也有可能是因为其它线程申请锁的时候,发现t1在用,就进行了阻塞等待 而挂起
只需要在解锁之后添加一个usleep
模拟其他工作,就能让所有线程都跑起来
1 2 3 4 5 6 7 8 9 10 pthread_mutex_lock (&mutex);if (water<cup){ usleep (100 ); cout << (char *)arg << " 水没有满:" << water << "\n" ; water++; pthread_mutex_unlock (&mutex); usleep (100 ); }
没有出现数据错误,加锁的目的成功达到!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 t1 水没有满:9993 t3 水没有满:9994 t4 水没有满:9995 t2 水没有满:9996 t1 水没有满:9997 t3 水没有满:9998 t4 水没有满:9999 t2 水已经满了 10000 t2 线程退出 t1 水已经满了 10000 t1 线程退出 t3 水已经满了 10000 t3 线程退出 t4 水已经满了 10000 t4 线程退出 ^Cpthread_mutex_destroy, exit
4.5 加锁的进一步解释 在这个代码示例中,我们给中间的几行代码加了锁;但这并不意味着执行中间这部分代码的时候,就不会发生线程切换
1 2 3 4 5 6 7 pthread_mutex_lock (&mutex);if (water<cup){ cout << (char *)arg << " 水没有满:" << water << "\n" ; water++; } pthread_mutex_unlock (&mutex);
事实上,代码执行的任何地方,都可能发生进程/线程的切换。但因为我们加了锁,切换的时候,其他线程要来访问这里的资源,就必须先申请锁
此时锁在被切走的进线程手上 ,所以其他线程无法访问临界区的资源,也就不会发生数据不一致的问题。
换言之,只要张三 拿到了锁,那么它也就不担心自己的工作会被别人覆盖的问题;
而对其他线程而言,张三访问临界区的工作,只有还没进入 临界区和访问完毕 临界区两种状态
因此会导致一个问题,那就是线程切换的效率较低,其他线程出现了阻塞等待的情况;为了避免此问题,我们应该让访问临界区的操作快去快回,尽量不要在临界区里面干啥耗时的事情
4.5.1 加锁原子性的保证 备注:这部分仅供学习参考,若有错误,还请指出!
那么加锁这个操作,是如何保证其自身的原子性呢?在加锁的途中不会发生线程切换吗?
我找到了一张能大概说明汇编加锁过程的图片,其中movb
的操作就是将al寄存器写为0,xchgb
的操作是将al寄存器的内容和内存中mutex锁的值进行交换
开始的时候,锁被正常初始化,内存中mutex的值为1(锁只会被初始化一次) 线程A开始加锁,al寄存器和mutex的值发生交换,此时内存中的mutex为0,al为1 判断al不为0,代表获取锁成功,线程A加锁成功 线程B也来申请锁了,movb
将al寄存器写为0,再和内存中的mutex交换后,发现还是0,则代表锁在别人手上,此时就需要挂起等待 前面一直强调,线程是有自己独立的栈结构和上下文数据的,在加锁的这部分汇编操作中,同样可能会在任何地方发生线程切换。切换的时候,线程的上下文数据 (图中寄存器的状态)会被保留下来,随这个线程一起被切换走
所以线程A被切换的时候,属于它上下文中那个值为1的al寄存器也被切走了(注意,这里切走的是数据,al寄存器本身作为硬件,有且只有一个 )
由此看来,真正获取锁的操作,其实只有xchgb
一条交换指令来完成,保证加锁操作只由一条汇编语句实现 ,就能保证该操作的原子性!
解锁的方法就很简单了,movb
将1写回mutex变量即可,也是一条汇编完成;而且一般情况下,解锁是不会有执行流和你抢的。
其实加锁远不止一种方法,锁的种类有非常多,还有总线锁、旋转锁 等等,每一个锁的实现都不太一样!上面提到的为互斥锁
4.5.2 总线锁 现在的CPU一般都有自己的内部缓存,根据一些规则将内存中的数据读取到内部缓存中来,以加快频繁读取的速度。现在服务器通常是多 CPU,更普遍的是,每块CPU里有多个内核,而每个内核都维护了自己的缓存,那么这时候多线程并发就会存在缓存不一致性,这会导致严重问题。
总线锁就是将cpu和内存之间的通信 锁住,使得在锁定期间,其他cpu处理器不能操作其他内存中数据,故总线锁开销比较大!
总线锁的实现是采用cpu提供的LOCK#
信号,当一个cpu在总线上输出此信号时,其他cpu的请求将被阻塞,那么该cpu则独占共享内存 ,相当于锁住了
CPU总线是所有CPU与芯片组连接的主干道,负责CPU与外界所有部件的通信 ,包括高速缓存、内存、北桥,其控制总线向各个部件发送控制信号、通过地址总线发送地址信号指定其要访问的部件、通过数据总线双向传输
5.死锁 死锁就是一种因为两放都不会释放对方需要的资源,从而陷入的永久等待状态
5.1 死锁情况演示 举个例子,张三拿了锁A,申请锁B的时候,发现锁B无法申请,而进入等待;李四拿了锁B,接下来他想申请锁A,结果发现张三拿着锁A,那就只能进入等待。这就陷入了一个僵局,张三想要李四的,李四想要张三的,谁都不让谁
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 #include <iostream> #include <string.h> #include <signal.h> #include <pthread.h> #include <thread> #include <unistd.h> #include <sys/types.h> #include <sys/syscall.h> using namespace std;pthread_mutex_t m1;pthread_mutex_t m2;void * func1 (void *arg) { while (1 ) { pthread_mutex_lock (&m1); pthread_mutex_lock (&m2); cout << "func1 is running... " <<(const char *)arg<<endl; pthread_mutex_unlock (&m1); pthread_mutex_unlock (&m2); } } void * func2 (void *arg) { while (1 ) { pthread_mutex_lock (&m2); pthread_mutex_lock (&m1); cout << "func2 is running... " <<(const char *)arg<<endl; pthread_mutex_unlock (&m1); pthread_mutex_unlock (&m2); } } int main () { pthread_mutex_init (&m1,nullptr ); pthread_mutex_init (&m2,nullptr ); pthread_t t1,t2; pthread_create (&t1,nullptr ,func1,(void *)"t1" ); pthread_create (&t2,nullptr ,func2,(void *)"t2" ); pthread_detach (t1); pthread_detach (t2); while (1 ) { cout << "main running..." <<endl; sleep (1 ); } pthread_mutex_destroy (&m1); pthread_mutex_destroy (&m2); return 0 ; }
上面的这个代码便能模拟出这个情况,线程1先要了锁1,再要锁2;线程2先要锁2再要锁1,他们俩就容易打起来,造成死锁。
运行代码的时候我们却发现,似乎并不是这样的,线程1好像还是成功拿到了俩把锁,并运行了起来
1 2 3 4 5 6 7 8 9 [muxue@bt-7274:~/git/linux/code/22-12-23_线程死锁]$ ./test main running... func1 is running... t1 func1 is running... t1 main running... func1 is running... t1 main running... func1 is running... t1 main running...
那是因为我们没有执行其他一些工作,从而将线程1和2申请锁的时间错开
将代码改成下面这样,利用usleep
让两个线程休眠不同时间,结果就不同了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 void * func1 (void *arg) { while (1 ) { pthread_mutex_lock (&m1); usleep (200 ); pthread_mutex_lock (&m2); cout << "func1 is running... " <<(const char *)arg<<endl; pthread_mutex_unlock (&m1); pthread_mutex_unlock (&m2); } } void * func2 (void *arg) { while (1 ) { pthread_mutex_lock (&m2); usleep (300 ); pthread_mutex_lock (&m1); cout << "func2 is running... " <<(const char *)arg<<endl; pthread_mutex_unlock (&m1); pthread_mutex_unlock (&m2); } }
可以看到,此时只有主线程在运行,线程t1和t2出现了死锁!
1 2 3 4 5 [muxue@bt-7274:~/git/linux/code/22-12-23_线程死锁]$ ./test main running... main running... main running... main running...
5.2 死锁的条件 互斥条件:某份资源同一时间只能由一个执行流访问 请求与保持:一个执行流因请求某种资源进入阻塞等待,而不释放自己的资源(好比上面代码例子中两个线程都不释放自己的锁,又想要别人的锁) 不剥夺条件:一个执行流已获得的资源,在未使用之前不能被剥夺(部分锁是允许被剥夺的) 循环等待:若干执行流之间形成一种头尾相接的循环等待资源的状态 一把锁也能造成死锁吗?答案是肯定的!
1 2 3 pthread_mutex_lock(&m1); pthread_mutex_lock(&m1);
如果有人写出这种bug代码,那就会出现一把锁把自己死锁了;死锁本来就是代码的bug,所以这种低级错误也是死锁的情况之一😂
5.3 避免死锁 避免死锁,其中最简单明了的办法,就是破坏上面提到的死锁的4个条件;其中互斥条件没啥好办法破坏(除非你不加锁),更主要的是看另外3个条件是否能破坏!
保持加锁顺序一致:不要出现上面代码中的线程a先申请锁1,线程b先申请锁2的情况。在不同的执行流中,按相同的顺序申请锁(比如线程a和b都是按锁1/2的顺序申请的)一定程度上能破坏请求与保持
条件 降低加锁的粒度:锁保护的区域变小,加锁的粒度减小,能一定程度上避免锁未释放 资源一次性分配:减少临时资源分开给的情况 允许抢占:线程之间依靠优先级抢夺锁,这种情况就是锁允许被剥夺 6.线程安全 线程安全:多个线程并发执行同一段代码的时候,不会 出现不同的结果
线程不安全的情况:
不保护临界资源 在多线程操作中调用不可重入 函数(概念见linux信号 部分) 返回指向静态变量的指针的函数 线程安全:
每个线程只操作局部变量,或者只对全局、静态变量只读不写 接口对线程来说是原子操作(被锁保护) 多个线程切换不会使函数接口的结果出现二义性 多线程操作不调用不可重入函数 注意,绝大多数的系统自带的库(比如C++的STL库)都是不可重入 的
不可重入是函数的一种性质 ,并不是它的缺点!如果一个库函数明明告知你了我是不可重入的,你还不加保护的在多线程操作中调用它,那么这段代码是有bug的 ,并不是库函数本身有问题