千呼万唤始出来,终于到多线程方面的学习了!
所用系统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
结构体中,有这么一个字段
折叠代码块 C
复制代码
1 2 struct thread_struct thread ;
转到定义,其内部都是一些寄存器 信息,用于标识这个线程的基本信息。这也是linux中没有单独 实现线程tcb的体现,而是用task_struct
来模拟的
折叠代码块 C
复制代码
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 人如其名,这个函数的作用是来创建新进程的
折叠代码块 C
复制代码
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
折叠代码块 C
复制代码
1 typedef unsigned long int pthread_t ;
创建线程后打印可以发现,线程id是一个非常大的值,并不像进程PID那么小
折叠代码块 CPP
复制代码
1 2 pthread_create 140689524995840 140689516603136
可以通过printf %x
的方式来减少打印长度
2.2 pthread_join 光是创建进程还不够,我们还需要对进程进行等待
折叠代码块 C
复制代码
1 2 3 #include <pthread.h> int pthread_join (pthread_t thread, void **retval) ;
这里第一个参数是线程的id,第二个参数是进程的退出状态
等待成功后返回0,否则返回错误码
join可以在线程退出后,释放线程的资源 同时获取线程对应的退出码 join还能保证是新创建的线程退出后,主线程才退出 2.2.1 基础的多线程操作 有了这两个,我们就能写一个简单的多线程操作了
折叠代码块 CPP
复制代码
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接口,基本的操作很相似
折叠代码块 CPP
复制代码
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
折叠代码块 C
复制代码
1 int pthread_join (pthread_t thread, void **retval) ;
我们可以使用该函数的第二个参数来获取线程所执行方法的返回值。retval
是一个二级指针,是一个输出型参数
折叠代码块 CPP
复制代码
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
函数实现退出
折叠代码块 C
复制代码
1 2 3 #include <pthread.h> void pthread_exit (void *retval) ;
效果完全一样
折叠代码块 C
复制代码
1 2 pthread_exit((void *)10 );
注意,主线程main中调用该函数,并不会导致进程退出
折叠代码块 CPP
复制代码
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里面直接把某一个线程给关掉
折叠代码块 C
复制代码
1 2 3 #include <pthread.h> int pthread_cancel (pthread_t thread) ;
折叠代码块 CPP
复制代码
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
折叠代码块 C
复制代码
1 2 3 #include <pthread.h> int pthread_detach (pthread_t thread) ;
一个线程是否应该等待,取决于是否需要获取该线程的返回值;如果无须获取返回值,则使用分离能提高运行效率
即便线程所运行的函数return是无效的,但我们可以用输出型参数来获取返回值
2.4.1 实操 使用也很简单,只需要指定线程的id就行了
折叠代码块 CPP
复制代码
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
会发生什么呢?
折叠代码块 CPP
复制代码
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就会失败
折叠代码块 CPP
复制代码
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
传入了一个无效的参数,线程依旧在正常运行
折叠代码块 C
复制代码
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
后,主线程提前退出了,会发生什么?
折叠代码块 CPP
复制代码
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函数
折叠代码块 C
复制代码
1 2 3 4 #define _GNU_SOURCE #include <unistd.h> #include <sys/syscall.h> int syscall (int number, ...) ;
在syscall的man手册中,我们就能看到获取线程id相关的示例
折叠代码块 C
复制代码
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); }
用下面的代码进行测试
折叠代码块 CPP
复制代码
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
中的部分内容
折叠代码块 C
复制代码
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 线程的局部存储 假设我们有一个全局变量,我们想让创建出来的每一个线程,都能独立的使用这个全局变量,那就需要用到线程的局部存储
折叠代码块 CPP
复制代码
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
折叠代码块 C
复制代码
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在设计之初就考虑到了这种问题,所以它便给我们提供了加锁相关的操作
折叠代码块 C
复制代码
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
使用默认属性
折叠代码块 C
复制代码
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 加锁/解锁 有了锁,那么就可以在需要的位置加上这把锁
折叠代码块 C
复制代码
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,装满了水就会溢出 。我们使用多个线程对这个杯子加水,直到满了之后线程退出
折叠代码块 CPP
复制代码
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循环中需要处理的负担 ,就能让所有线程都来倒水
折叠代码块 C
复制代码
1 2 3 #include <unistd.h> int usleep (useconds_t usec) ;
这是因为线程切换同样也是时间片到了,从内核返回用户态的时候做检测,切换至其他线程。
添加usleep能创造更多内核/用户的中间态 ,从而增多切换线程的次数
折叠代码块 CPP
复制代码
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 加锁-问题解决 这时候就需要请出我们的锁了
折叠代码块 CPP
复制代码
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 ; }
运行可见,数字错误问题就没有出现了;但又出现了只有一个线程工作的问题
折叠代码块 CPP
复制代码
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
模拟其他工作,就能让所有线程都跑起来
折叠代码块 CPP
复制代码
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 加锁的进一步解释 在这个代码示例中,我们给中间的几行代码加了锁;但这并不意味着执行中间这部分代码的时候,就不会发生线程切换
折叠代码块 CPP
复制代码
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,那就只能进入等待。这就陷入了一个僵局,张三想要李四的,李四想要张三的,谁都不让谁
折叠代码块 CPP
复制代码
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
让两个线程休眠不同时间,结果就不同了
折叠代码块 CPP
复制代码
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 死锁的条件 互斥条件:某份资源同一时间只能由一个执行流访问 请求与保持:一个执行流因请求某种资源进入阻塞等待,而不释放自己的资源(好比上面代码例子中两个线程都不释放自己的锁,又想要别人的锁) 不剥夺条件:一个执行流已获得的资源,在未使用之前不能被剥夺(部分锁是允许被剥夺的) 循环等待:若干执行流之间形成一种头尾相接的循环等待资源的状态 一把锁也能造成死锁吗?答案是肯定的!
折叠代码块 C
复制代码
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的 ,并不是库函数本身有问题