1 🍑线程概念 🍑
1.1 🍎什么是线程?🍎
教材观点是这样的:线程是一个执行分支,执行力度比进程更细,调度的成本更低。
Linux内核观点:进程是系统分配资源的基本单位,线程是CPU调度的基本单位。
这两种说法都是正确的,但是我们究竟该如何理解线程呢?
在一个程序里的一个执行路线就叫做线程(thread)。更准确的定义是:线程是“一个进程内部的控制序列”。一切进程至少都有一个执行线程。线程在进程内部运行,本质是在进程地址空间内运行。在Linux系统中,在CPU眼中,看到的PCB都要比传统的进程更加轻量化。透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流。
在Linux中其实本质上并不存在线程,而是将轻量级进程作为线程。其本质就是OS并没有给线程创建自己独立的地址空间,而是与进程共用一套地址空间,那么这也就注定了线程中绝大部分资源是可以共享的。这样设计的好处是复用了PCB的那一套设计,线程的TCB可以用进程的PCB模拟出来,这样的设计更加简单并且维护效率更加高效,像服务器等开发选择Linux的原因就是因为Linux可以在长时间的服务中运行。Windows中的线程才算的上是一种严格的线程,Windows的线程并没有复用进程的方法,而是创造了真正意义上的线程。
我们可以用一张图来表示进程与线程的关系:
1.2 🍎线程的优点和缺点🍎
线程的优点:
1️⃣创建一个新线程的代价要比创建一个新进程小得多。
2️⃣与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多。
3️⃣线程占用的资源要比进程少很多。
4️⃣能充分利用多处理器的可并行数量。
5️⃣在等待慢速I/O操作结束的同时,程序可执行其他的计算任务。
6️⃣计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现。
7️⃣I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
线程的缺点:
1️⃣性能损失:
一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
2️⃣健壮性降低
编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
3️⃣缺乏访问控制
进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。
4️⃣编程难度提高
编写与调试一个多线程程序比单线程程序困难得多。
1.3 🍎页表的大小🍎
这里再补充一个小知识点:多线程中页表关系是怎样维护建立的呢?
我们知道,在X86的环境下,我们最多可以拥有232种虚拟地址,而将虚拟地址转化成物理地址的页表大小应该为多少呢?
如果按照每一个虚拟地址都建立一个对应映射的话,假设用一个四字节的整形变量int来维护,那么不算其他的,只算虚拟地址到物理地址的映射,那么至少得需要232 *8(大约32GB),那我们操作系统还玩不玩了,这样设计肯定是不合理的。
实际上,操作系统将每一个32比特位的虚拟地址做了如下划分:
这样进行页表大小计算时我们用的是前20个比特位,也就是220 ,然后通过下面方式进行页表映射:
那么最后12位到哪里去了呢?最后12位的虚拟地址是我们将虚拟地址转化位物理地址的偏移量,这个偏移量的大小恰好是212 (4KB),这个4KB是操作系统管理物理内存的单位,相信大家对于4KB一点儿也不陌生,因为我们讲解文件系统的时候磁盘与内存进行交互的单位也是以4KB位单位。这里为什么要使用4KB的大小进行交互而不是以字节进行交互呢?因为一个很著名的原理:局部性原理。通俗的来说,局部性原理就是预测未来CPU高速缓存的命中情况来提升效率。
所以通过这种方式我们只用了220 量级的大小空间来完成页表的建立,最多也就几MB而已,更何况并不是所有地址都会被用到,实际用到的地址可能只有几十字节大小。所以这种方式可以解决操作系统如何为页表分配合适的空间问题。
那么实际操作系统是如何分配资源给对象的呢?比如我们使用malloc一个资源是立马就会给你开空间的吗?显然不是这样的,操作系统为了高效是不会直接立马给你开空间的,而是产生一个缺页中断,当你真正使用该空间时才会去开空间。
1.4 🍎线程异常和用途🍎
线程异常:
单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃。
线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出。
这里我们可以简单的验证一下,大家先可以先看看时如何创建线程的,后面我们会详细的讲解:
比如下面的我们让线程1出错:
Makefile:
mytest:Test.cpp g++ -o $@ $^ -std=c++11 -lpthread .PHONY:clean clean: rm -rf mytest
Test.cpp:
#include<iostream> #include<pthread.h> #include<unistd.h> using namespace std; void *Run1(void *argv) { int cnt = 4; while (true) { cout << "I am t1,is running" << endl; sleep(1); if (--cnt == 0) { char *str = "abcd"; *str = 'Q'; } } } void* Run2(void* argv) { while(true) { cout<<"I am t2,is running"<<endl; sleep(1); } } int main() { pthread_t t1,t2; pthread_create(&t1,nullptr,Run1,nullptr); pthread_create(&t2,nullptr,Run2,nullptr); while(true) { cout<<"I am main,is running"<<endl; sleep(1); } return 0; }
这里面值得注意的细节:创建线程时要引入头文件<pthread.h> ;链接时为了能够找到库,在Makefile种要指定库的名称-lpthread,要查找指定进程的所有线程可以使用下面命令:ps -aL | grep 进程名称;想要显示更加详细信息可以使用下面命令:ps -aL | head -1 && ps -aL | grep 进程名称 ;为了方便观察我们可以使用下面的命令脚本:while :;do ps -aL | head -1 && ps -aL | grep mytest;echo "************************************";sleep 1;done
我们来运行下观察下结果:
从图片中我们不难发现当其中一个线程崩溃而导致整个进程(所有线程)都挂掉了。
线程用途:
- 合理的使用多线程,能提高CPU密集型程序的执行效率。
- 合理的使用多线程,能提高IO密集型程序的用户体验(如生活中我们一边写代码一边下载开发工具,就是多线程运行的一种表现)
1.5 🍎进程VS线程🍎
- 进程是资源分配的基本单位
- 线程是调度的基本单位
- 线程共享进程数据,但也拥有自己的一部分数据:
- 1️⃣线程ID
2️⃣一组寄存器
3️⃣栈
4️⃣errno
5️⃣信号屏蔽字
6️⃣调度优先级
进程的多个线程共享同一地址空间,因此Text Segment、Data Segment都是共享的,如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境:
1️⃣文件描述符表
2️⃣每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)
3️⃣当前工作目录
4️⃣用户id和组id
进程与线程的关系如下图:
2 🍑线程控制🍑
2.1 🍎POSIX线程库🍎
- 与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以“pthread_”打头;
- 要使用这些函数库,要通过引入头文
<pthread.h>
; - 链接这些线程函数库时要使用编译器命令的“
-lpthread
”选项.
2.2 🍎创建线程🍎
我们先来看看库中的基本介绍:
功能:创建一个新的线程
原型:
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void*), void *arg)
参数:
thread:返回线程ID;
attr:设置线程的属性,attr为nullptr表示使用默认属性;
start_routine:是个函数地址,线程启动后要执行的函数;
arg:传给线程启动函数的参数;
返回值:成功返回0;失败返回错误码。
这里再补充一个错误检查的知识点:
传统的一些函数是,成功返回0,失败返回-1,并且对全局变量errno赋值以指示错误。pthread函数出错时不会设置全局变量errno(而大部分其他POSIX函数会这样做)。而是将错误代码通过返回值返回。
pthread同样也提供了线程内的errno变量,以支持其它使用errno的代码。对于pthreads函数的错误,建议通过返回值来判定,因为读取返回值要比读取线程内的errno变量的开销更小。
我们观察上面创建线程的接口中,第一个参数是线程的id,这个的数值与之前我们在用监视脚本看的线程PID不太一样,现阶段可以理解为同一线程的两种不同的身份形式(比如我们在学校的学生证和处于社会中的身份证类似);第二个参数我们一般设置为空;第三个是一个参数为void*,返回值为void的函数指针;第四个参数是一个void的对象,我们一般是将线程的信息通过该参数传递进去的。
我们根据上面介绍就可以写出如下代码:
#include<iostream> #include<unistd.h> using namespace std; void* Run(void* args) { const char* name=static_cast<char*> (args); while(true) { cout<<name<<"is running"<<endl; sleep(1); } return nullptr; } int main() { pthread_t pids[5]; for(int i=0;i<5;++i) { char name[26]; snprintf(name,sizeof(name),"pthread%d:",i+1); pthread_create(pids+i,nullptr,Run,name); } while(true) { cout<<"I am is main thread,is running"<<endl; sleep(1); } return 0; }
当我们运行时:
为啥跟我们预计的不太一样呀?我们想要的是打印pthread1 pthread2……这样的数据呀,为啥打印出来的都是pthread5呢?
这其实与我们传入的数组有关:
我们在这里传入的是数组名,也就是首元素地址,我们传给线程创建的参数并不是一个缓冲区而是一个数组的地址,由于创建线程是先将线程创建出来,并不会立马去执行线程中的代码,而我们每次传入的地址(数组名)是相同的,所以最后一个线程的数据就被保存到了数组中,当我们并发执行线程中的代码时读到的就是数组中的数据(也就是最后一次修改数组中的数据)。那我们如何解决这种现象呢?
我们可以在堆上开辟空间,这样我们每次new出来的地址是不同的,所以就不会出现覆盖的情况了。
比如我们可以这样修改:
当我们再次运行时:
这里面打印顺序并不是1 2 3 4 5那样的原因是因为线程的调度也是不确定的,谁先调度完全是由调度器所决定的。
其实上面传入的对象大家可以更具需求设置的更加完善一些,我们可以封装一个类,让多线程帮助我们完成不同的任务,我这里就不再多写了,大家有兴趣可以根据自己的需求下去完善。
线程ID及进程地址空间布局:
pthread_ create函数会产生一个线程ID,存放在第一个参数指向的地址中。该线程ID和前面说的线程ID不是一回事。
前面讲的线程ID属于进程调度的范畴。因为线程是轻量级进程,是操作系统调度器的最小单位,所以需要一个数值来唯一表示该线程。
pthread_ create函数第一个参数指向一个虚拟内存单元,该内存单元的地址即为新创建线程的线程ID,属于NPTL线程库的范畴。线程库的后续操作,就是根据该线程ID来操作线程的。线程库NPTL提供了pthread_ self函数,可以获得线程自身的ID.