文章目录
- 认识线程
- 创建线程
- 线程优点和缺点
- 创建一批线程
- 终止线程
- 线程的等待问题
认识线程
- 在一个程序里的一个执行路线就叫做线程(thread)。更准确的定义是:线程是“一个进程内部的控制序列”
- 一切进程至少都有一个执行线程
- 线程在进程内部运行,本质是在进程地址空间内运行
- 在Linux系统中,在CPU眼中,看到的PCB都要比传统的进程更加轻量化
- 透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流
上面这张图中出现的内容都已经在前面的文章中解释过,这里再对页表内容做一个补充。
我们都知道,在语言中,定义在常量区中的变量是不可被修改的,为什么?
答:常量区存在于已初始化数据和代码区之间。当修改常量区中的内容时,要先找到变量所在的地址,再经过页表映射到具体的物理内存进行操作。但是在页表映射时,页表会检查RWX权限和要进行的操作。常量区的变量所具有的权限是R,所以自然不能进行写操作。MMU就会向硬件报错,进而被OS所识别,然后会将报错转变成信号(段错误)发送给进程,然后进程会以默认动作(终止)来处理该进程。
如何看待进程地址空间和页表?
- 地址空间是进程能看到的资源窗口
- 页表决定进程真正拥有资源的情况
- 合理地对页表和地址空间进行资源划分就可以对一个进程所有的资源分类
进程地址空间是如何经过页表找到具体的物理内存的?
首先,OS要对物理内存进行管理,就要把物理内存分成一个个的物理页(每一个物理页大小为4KB),每一个物理页都有描述自己属性的结构体,然后OS会用数组的方式来组织起所有的物理页,进而管理整个物理内存。
而内存中的数据与物理内存进行交互时,传输数据的单位也是4KB(这样的一块数据在磁盘中叫做页帧)。最后,OS会用特定的管理算法对所有物理页进行管理。
以32为平台为例,每一个虚拟地址都有32个比特位。而虚拟地址经过页表映射时,不是将32个比特位直接映射到物理内存,而是将虚拟地址划分为10个、10个和12个比特位进行三次映射,最后到达具体的物理内存。
页表包括页目录和页表项:
前10个比特位一共有1024个组合,所以页目录里也会有1024个与之对应的位置,页表项也是如此。
现根据前十个比特位在页目录中找到具体的页表项,再根据下一组十个比特位在页表项中找到物理内存中的具体某一个物理页,最后根据后十二个比特位(相对物理页起始位置的偏移量)找到数据在物理页中的具体位置。
而每一个物理页的大小是4KB,也就刚好对应后12个比特位可能出现的组合的数量。
以上是对进程概念的补充。在我之前的文章中解释的进程就是内核数据结构+进程对应的代码和数据。而现在要说的线程就是进程内的一个执行流。
如何理解“线程是进程内的一个执行流”?
上文中说了“虚拟内存决定了进程能看到的资源”,而当我们用fork创建子进程时,可以让父进程和子进程分别执行不同的操作。
那么如果我们现在创建一批子进程,但是只给这些子进程创建属于自己的PCB,而不给它们创建进程地址空间和页表等,并把它们的PCB指向和父进程一样的进程地址空间,再把进程地址空间中的各个区域划分给不同的子进程供其使用(同时将部分资源划分给子进程)。这时,这些子进程就相当于父进程中的执行流,而我们也可以认为新创建出的一个个PCB就是一个个线程。
而因为我们可以通过进程地址空间+页表的方式对进程进行资源划分,所以单个线程的执行力度一定会比原来的进程更细节。
创建线程
如果OS要设计线程的概念,那么要不要对线程进行管理?如何管理?
答:如果有线程的概念,就一定要想办法对其进行管理,而管理的方法就像进程一样,要为它设计专门的数据结构来表示一个线程对象,再把它们组织起来。而Windows中就是这么做的,其中描述线程的数据结构叫做TCB。
可是,如果有了线程的概念和数据结构,当它被调度执行的时候,就一定需要ID 状态 优先级 上下文 栈等概念,这样看来,线程和进程有很多方面都是重复的。所以早期的Linux工程师们就不再专门设计“线程”的概念,而是直接对进程中的PCB进行复用,就成了我们现在所说的“线程”。
所以,Linux中根本就不存在线程的概念。在Linux中,进程就是承担分配操作系统资源的的基本实体,而线程是CPU调度的基本单位。一个进城内部可以有多个执行流,而认为单个进程内部只有一个执行流。
下面用代码证明一下以上内容:
首先要了解创建线程的函数:
代码如下:
对应的Makefile内容如下:
运行结果如下:
[sny@VM-8-12-centos practice]$ make clean;make
rm -f mythread
g++ -o mythread mythread.cpp
/tmp/ccLOh0U0.o: In function `main':
mythread.cpp:(.text+0x53): undefined reference to `pthread_create'
collect2: error: ld returned 1 exit status
make: *** [mythread] Error 1
[sny@VM-8-12-centos practice]$
出现这种情况的原因是,找不到创建线程所依赖的库。
因为Linux没有为创建线程专门的系统调用接口,所以只能依赖库来实现,解决方案如下:
(这部分在前面动静态库的文章中有解释)
[sny@VM-8-12-centos practice]$ vim Makefile
[sny@VM-8-12-centos practice]$ make clean;make
rm -f mythread
g++ -o mythread mythread.cpp -lpthread
[sny@VM-8-12-centos practice]$ ls
core.30230 Makefile mytest mythread mythread.cpp
[sny@VM-8-12-centos practice]$
运行成功!
程序所连接到的库如下:
[sny@VM-8-12-centos practice]$ ldd mythread
linux-vdso.so.1 => (0x00007ffcc13dc000)
libpthread.so.0 => /lib64/libpthread.so.0 (0x00007f0b44956000)
libstdc++.so.6 => /home/sny/.VimForCpp/vim/bundle/YCM.so/el7.x86_64/libstdc++.so.6 (0x00007f0b445d5000)
libm.so.6 => /lib64/libm.so.6 (0x00007f0b442d3000)
libgcc_s.so.1 => /lib64/libgcc_s.so.1 (0x00007f0b440bd000)
libc.so.6 => /lib64/libc.so.6 (0x00007f0b43cef000)
/lib64/ld-linux-x86-64.so.2 (0x00007f0b44b72000)
[sny@VM-8-12-centos practice]$ ls /lib64/libpthread.* -al
-rw-r--r-- 1 root root 152194 May 19 2022 /lib64/libpthread.a
-rw-r--r-- 1 root root 222 May 18 2022 /lib64/libpthread.so
lrwxrwxrwx 1 root root 18 Jul 25 2022 /lib64/libpthread.so.0 -> libpthread-2.17.so
[sny@VM-8-12-centos practice]$
libpthread-2.17.so这个库叫做用户线程库/原生线程库,任何Linux系统都必须含有,在其内部实际上含有系统调用接口。
对于上面的代码,如果两个线程在一个执行流中,就不可能同时运行两个while循环。所以,根据运行结果就可以验证“线程是进程中的一个执行流”以及“一个进程中可以有多个执行流”的说法,结果如下:
通过运行结果可以里看到,虽然有两个线程在跑,但系统中只有一个进程,也就是说两个执行流在同一个进程内,当kill掉这个进程之后,两个线程都被终止了。
那么怎么查看两个执行流具体的信息?
方法如下:
执行ps -aL命令可以看到连个线程。
上面两个线程PID相同,但是LWP不相同,LWP即为light weight process,也就是轻量级进程。
另外,可以看到第一个线程的LWP和PID是相同的!!!这表明第一个线程为主线程,第二个线程为新线程。
所以,CPU进行调度时,是以LWP为标识符表示一个特定的执行流的(只不过之前代码中每个进程只有一个执行流,所以PID和LWP没区别)
下面来验证一下pthread_create的第四个参数是第三个参数指向的函数的参数这句话,对代码稍作改动,如下:
结果如下:
[sny@VM-8-12-centos practice]$ make clean;make
rm -f mythread
g++ -o mythread mythread.cpp -lpthread
[sny@VM-8-12-centos practice]$ ./mythread
主线程running!新线程running! name : thread one
主线程running!
新线程running! name : thread one
新线程running! name : 主线程running!thread one
^C
[sny@VM-8-12-centos practice]$
接下来看一下pthread_create的第一个参数(tid)的值:
结果如下:
[sny@VM-8-12-centos practice]$ ./mythread
主线程running! tid: 新线程running! name : thread one140114343909120
主线程running! tid: 140114343909120
新线程running! name : thread one
主线程running! tid: 140114343909120新线程running! name :
thread one
这一长串数字显然看不懂什么意思,用十六进制输出一下试试:
结果如下:
[sny@VM-8-12-centos practice]$ ./mythread
主线程running! tid: 0xdb2f1700新线程running! name : thread one
新线程running! name : thread one主线程running! tid: 0xdb2f1700
主线程running! tid: 0xdb2f1700新线程running! name :
thread one
现在还是看不懂,暂且先把这个问题放在这儿,下文再做解释。
现在开始一个新的话题:线程一旦被创建,几乎所有的资源都是被所有的线程所共享的。也就是说,一个进程中的不同执行流可以看到彼此所能看到的资源,当其中一个执行流更改数据时,其他执行流也可以看到更改后的数据。(感兴趣的可以自己写一小段代码验证一下。)所以,线程之间进行数据交换和通信是非常容易的。
但是线程之间也有自己私有的资源,包括:
- PCB属性
- 上下文结构
- 线程独立的栈结构
线程优点和缺点
优点:
①创建一个新线程的代价要比创建一个新进程小得多
这一点在上文中已经解释过了,这里不再赘述。
②与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
原因如下:
第一,进程之间切换时,需要切换的内容有页表、虚拟地址空间、PCB和上下文;但进程之间相互切换只需要切换PCB和上下文。
这样看起来,进程和线程切换效率并没有很大差别,那它们的效率差距体现在哪儿呢?
这里需要补充一个cache的概念。
cache是存在于CPU中的一个硬件缓存,它的运行速率比CPU慢,但是远高于内存。其中存储的主要是一些当前执行流经常用到以及很可能用到的热点数据,目的是当执行流需要读取这些数据时可以更快速地完成。
由于线程之间大部分资源是共享的,所以切换时也就不用切换cache中的数据;但是进程切换却要将cache中的数据全部换掉,太频繁的切换甚至会导致cache失效,效率就会大大降低,这就是差距所在。
③线程占用的资源要比进程少很多
④能充分利用多处理器的可并行数量
⑤在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
⑥计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
⑦I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
这几点很好理解,不再赘述。
缺点:
① 性能损失
一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
②健壮性降低
编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
③缺乏访问控制
进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。
⑤编程难度提高
编写与调试一个多线程程序比单线程程序困难得多
这里解释一下什么是“线程健壮性”:
在我们上面的代码中一共有两个线程,那么如果其中一个线程出问题,会不会影响另一个线程?
用代码测试一下:
可以看到代码中有一个很明显的错误,运行结果如下:
[sny@VM-8-12-centos practice]$ ./mythread
主线程running! tid: 0x365f7700新线程running! name : thread one
主线程running! tid: 0x365f7700
Segmentation fault
可以看到,两个线程都被终止了。
上面的现象就说明线程的健壮性较差!因为信号是发送给进程整体的,当操作系统检测到异常时,会向进程发送信号,由于每一个人线程的PID都是一样的,所以都会对信号做出处理。
从资源的视角来看,操作系统向进程发送信号,随后回收进程资源。由于线程之间资源大部分共享,所以其他线程也就不存在了。
操作系统提供的创建轻量级进程的接口是什么?
实际上,上文中的代码中底层就调用了这个函数。
还有一个跟fork有些区别的接口:
它和fork的区别在于,vfork创建出来的进程具有相同的地址空间,说白了,也就是创建轻量级进程的意思,但这个不常用。
创建一批线程
上文中的代码只是创建了一个线程,这里再创建一批线程,代码如下:
结果如下:
[sny@VM-8-12-centos practice]$ ./mythread
new thread create successfully,name: main thread新线程running! name : thread:2
新线程running! name : thread:2
新线程running! name : thread:2
new thread create successfully,name: main thread
新线程running! name : thread:2
新线程running! name : thread:2
新线程running! name : thread:2
new thread create successfully,name: main thread
新线程running! name : thread:2
新线程running! name : thread:2
新线程running! name : thread:2
新线程running! name : thread:2
新线程running! name : thread:2
新线程running! name : thread:2
new thread create successfully,name: main thread
这时,出现的线程只有2号线程,这是有点奇怪的。
对上面的代码稍作改动,在其中加入一个sleep函数,如下:
再次运行,结果如下:
[sny@VM-8-12-centos practice]$ ./mythread
新线程running! name : thread:0
新线程running! name : thread:1
新线程running! name : thread:1
新线程running! name : thread:2
新线程running! name : thread:2
新线程running! name : thread:2
新线程running! name : thread:2
新线程running! name : thread:2
new thread create successfully,name: main thread
可见,这次的运行结果出现了0和1号线程。为什么只有一个sleep的区别,结果却不一样了?
答:①当创建新的线程之后,新线程根据缓冲区的起始地址去执行相应的任务,而主线程依然在原先的轨道上正常地进行,注意这里的传给新线程的是缓冲区的地址。
②创建新的线程之后,几个线程的执行顺序是随机的。而如果先执行的是主线程,它就会继续创建线程。而上面说传递给新线程的是缓冲区的地址,在下一次创建线程时,就会被新的线程地址覆盖,所以最后每一个新线程拿到的都是同一个缓冲区地址。
所以就会出现以上现象。
所以上面创建线程的方式其实是错误的,下面介绍正确的方式:
用创建对象的方式确保每一个线程收到的缓冲区地址不会被覆盖,运行结果如下:
[sny@VM-8-12-centos practice]$ ./mythread
新线程running! name : thread:0cnt : new thread create successfully,name: main thread3
新线程running! name : thread:1cnt : 3
新线程running! name : thread:2cnt : 3
new thread create successfully,name: main thread新线程running! name :
thread:1cnt : 2
新线程running! name : thread:0cnt : 2
新线程running! name : thread:2cnt : 2
新线程running! name : thread:1cnt : 1
new thread create successfully,name: main thread
新线程running! name : thread:0cnt : 1
新线程running! name : thread:2cnt : 1
new thread create successfully,name: main thread
new thread create successfully,name: main thread
new thread create successfully,name: main thread
这里需要明确三点:
①start_routine函数现在在被三个线程执行,所以它是重入状态。
②因为start_routine函数可以被多个线程执行,而且不会出现其他错误,所以它是可重入函数。
③在函数体内定义的变量是局部变量,具有临时性,在多线程情况下亦是如此,所以函数中的cnt在每个线程内部都是唯一的,即不重复的。
终止线程
其实在上面的代码中,我们使用return就可以直接终止一个线程了。
但是注意,使用exit终止的不是一个线程,而是进程,即一个进程中所有的执行流。二者不可混淆!
而让单个线程退出而不影响其他线程还可以调用pthread_exit函数:
这个方法的效果其实跟return是一样的。这里就不演示了。
线程的等待问题
同进程一样,线程退出的时候,也是需要释放资源的,否则就会造成内存泄漏等问题。所以也是需要被等待的,否则将会造成类似于僵尸进程的结果。
而我们等待线程主要是为了获取线程的退出信息以及回收线程对应的PCB等内核资源,防止内存泄漏。这一点跟进程很相像。
而等待线程的方法是pthread_join:
代码实例如下:
执行结果如下:
[sny@VM-8-12-centos practice]$ ./mythread
新线程running! name : thread:0cnt : 新线程running! name : thread:1cnt : 33
create thread: thread:0:139960118281984successfully!
create thread: thread:1:139960109889280successfully!
create thread: thread:2:139960101496576successfully!
新线程running! name : thread:2cnt : 3
新线程running! name : thread:0cnt : 2
新线程running! name : thread:1cnt : 2
新线程running! name : thread:2cnt : 2
新线程running! name : thread:0cnt : 1
新线程running! name : thread:2cnt : 1
新线程running! name : thread:1cnt : 1
join : thread:0successfully!
join : thread:1successfully!
join : thread:2successfully!
主线程退出!
由于线程退出是一瞬间的事,所以不能观察到它的中间过程。
下面解释一下pthread_join的第二个参数void** retval:
这其实是一个输出型参数,主要用来获取线程函数退出时,返回的退出结果,也就是下面这个函数的返回值。
为进一步理解这个参数,对代码稍作改动:
注意这里编译运行的时候会报警告,因为将int强转为了void*,但是不重要,直接看运行结果:
sny@VM-8-12-centos practice]$ ./mythread
create thread: thread:0:新线程running! name : thread:0cnt : 140010619401984successfully!3
新线程running! name : thread:2cnt : 3
create thread: thread:1:140010611009280successfully!
create thread: thread:2:140010602616576successfully!
新线程running! name : thread:1cnt : 3
新线程running! name : thread:0cnt : 2
新线程running! name : thread:1cnt : 2新线程running! name : thread:2cnt : 2
新线程running! name : thread:0cnt : 1新线程running! name : 新线程running! name : thread:1cnt : 1
thread:2cnt :
1
join : thread:0successfully! number:0
join : thread:1successfully! number:1
join : thread:2successfully! number:2
主线程退出!
其实上面的代码就是证明了pthread_join函数可以接收到线程退出时的返回值,而这个返回值就存在我们使用pthread_join函数时传入的第二个参数中。
本篇结束,多线程未完待续!