文章目录
1 Linux线程概念
1.1 什么是线程
- 在一个进程中的一个执行流叫做线程,即线程是“一个进程内部的控制序列”
- 所有进程至少都有一个线程(执行流)
- 线程在进程内部运行,本质是在进程地址空间中运行
- 在Linux系统中,CPU看到的task_struct要比真正的PCB更加轻量化
- 进程将资源合理分配给每个执行流,就形成了线程执行流
- 站在内核的角度,进程是承担分配系统资源的基本实体。
![[Pasted image 20220209214148.png]]
简单介绍页表映射
在32位平台下,有 2 32 2^{32} 232地址,假设一对映射需要10字节空间的话,那么 2 32 2^{32} 232对映射关系需要 2 32 ∗ 10 2^{32}*10 232∗10字节,也就是40GB,这实在是太大了,所以系统不是这个设计的。
在32位平台下采用的时候页目录+二级页表的方式。
一个地址有32个比特位,首先根据前10个比特位在有 2 10 2^{10} 210对映射关系的页目录中找到映射地址,然后根据这个地址找到对应的二级页表,根据32位地址中的次10个比特位在有 2 10 2^{10} 210对映射关系的二级页表中找到映射地址。最后根据这个地址在物理内存中到一个页框的首地址(物理内存的基本单位是页框,大小为 2 12 2^{12} 212)然后根据32位地址中的最后12个比特位在这个页框中找到物理内存的偏移位置,这样就可以找到对应的位置。
其中页目录和二级页表所有的映射关系都映射起来最多只有 2 20 2^{20} 220,也就是1MB。主要是由于物理内存页框的特殊特技可以省去使用 2 20 2^{20} 220个有 2 12 2^{12} 212对映射关系的页表了。类似的,64位下使用的是多级页表来进行相似的映射。
而且Linux采用软件页表+硬件MMU(memory manage unit,内存管理单元)来完成虚拟地址到物理地址的映射的。
缺页中断
如果访问期间,目标资源不在内存,则会出发缺页中断,进行内存分配,页表创建,建立映射关系。
比如说在malloc
的时候,其实系统根本没有给进程分配物理内存,也没有建立页表映射,只不过是在虚拟内存中分配了一块空间而已,只有当用户使用这块空间的时候,才会在物理内存中开辟空间,建立页表中的映射关系。
站在CPU的角度,能否识别task_struct是进程还是线程?
不能,CPU只关心独立的执行流,也就是进程中的线程。
Linux下不存在真正意义上的多线程
当线程足够多的时候,操作系统就需要使用特定的数据结构来管理线程。但是Linux下并没有这样的结构。所以在Linux下的线程是由进程模拟出来的。
在CPU调度的时候,看到的都是struct thread_info
这个结构体,结构体中包含了一个变量struct task_struct
。
所以task_struct
要比进程控制块包含的内容要少。
在Linux中的所有执行流,都叫做“轻量级进程”。
如何使用线程?
因为Linux中没有真正意义上的线程,所以Linux中也就没有真正意义上的线程相关的系统调用。但是提供了创建轻量级进程的接口。
#include <sys/types.h>
#include <unistd.h>
pid_t vfork(void);
- 作用
- 创建一个子进程,但是父子进程共享进程地址空间
基于轻量级进程的系统调用,有原生线程库在用户层模拟出一套线程接口。这个原生线程库叫做pthread
库。
1.2 线程优点
- 创建一个新线程的代价要比创建一个新进程小得多
- 线程之间的切换需要操作系统的做的工作少很多,要比切换一个进程简单
- 线程占用的资源要比进程少很多
- 能充分利用多处理器的可并行数量
- 在等待慢速I/O操作结束时,可以执行其他的计算任务
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
- 计算密集型:执行流的大部分任务主要以计算为主
- I/O密集型应用,为了提高性能,将I/O操作重叠,线程可以同时等待不同的I/O操作
- IO密集型:执行流的大部分任务主要是以IO为主
1.3 线程的缺点
- 性能损失
- 健壮性降低
- 如果一个线程访问了全局资源会影响其他资源,导致线程不安全。一个进程中的任何一个线程崩溃了,其他所有的线程全部崩溃。
- 缺乏访问控制
- 进程是访问控制的基本单位,线程对函数的访问缺乏控制
- 编程难度提高
- 编写和调试多线程程序比单线程程序困难
1.4 线程异常
- 单个线程如果出现除零,访问野指针,越界等问题导致线程线程崩溃的话,进程也会随着崩溃
- 线程是进程的执行分支,如果线程出现异常,触发了信号机制的话,就会终止进程,回收进程中的所有资源
1.5 Linux进程和线程
- 进程是资源分配的基本实体
- 线程是调度的基本实体
- 线程共享进程数据,但是也拥有自己的一部分数据
- 线程ID
- 一组寄存器(线程可以被调度)
- 栈(线程会产生自己的数据)
- errno
- 信号屏蔽字
- 调度优先级
进程的多个线程共享
多线程共享如下:
- 同一个地址空间
- 因此代码区和数据区都是共享的。如果定义一个函数,各线程都可以调用;如果有一个全局变量,各线程也都可以访问。此外
- 文件描述符表
- 每种信号的处理方式
- 当前工作目录
- 用户id和组id
常见的线程和进程关系:
![[Pasted image 20220210094404.png]]
2 Linux线程控制
2.1 POSIX线程库
上文说过,Linux下没有真正意义上的线程库,所以Linux使用引进的第三方库pthread
库来控制线程。
POSIX
线程库与线程有关的函数构成一个完整的系列,对绝大多数的函数都是以pthread_
打头的- 需要引进头文件
<pthreah.h>
- 链接这些线程函数库时,要是用编译器命令的
-lpthread
选项包含第三方线程库
2.2 线程创建
#include <pthread.h>
int pthread_create(pthread_t* thread, const phtread_attr_t *attr, void*(start_routine)(void*), void* arg);
- 作用
- 创建一个新的线程,可以指定新的线程需要执行的任务
- 参数
thread
:输出型参数,可以返回线程的线程号attr
:设置线程的属性start_routine
:需要指定线程需要执行的任务arg
:给线程执行的任务传入的参数
- 返回值
- 成功放回0,失败返回错误码
错误检查
- 传统的函数都是成功返回0,失败返回-1,并且对全局errno赋值表示错误
pthread
函数出错不会设置全局的errno(大部分POSIX函数都是这样),而是将错误码直接返回- 每个线程也都有自己的errno来支持其他需要支持设置errno的函数,但是读取返回值要比读取errno的开销小
示例:
创建一个线程,每隔一秒打印自己的pid
和ppid
,同时主线程也每隔一秒打印自己的pid
和ppid
。
注意:在编译文件的时候,需要gcc -lpthread
带上链接pthread
库的选项
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
void* Routine(void* arg)
{
char* msg = (char*)arg;
while (1)
{
printf("%s: pid: %d ppid: %d\n",msg, getpid(), getppid());
sleep(1);
}
return NULL;
}
int main()
{
pthread_t tid;
pthread_create(&tid, NULL, Routine, (void*)"thread 1");
while (1)
{
printf("main thread: pid: %d ppid: %d\n", getpid(), getppid());
sleep(1);
}
return 0;
}
由实验可知:
-
![[Pasted image 20220210101443.png]]
通过试验可以看出,创建的新线程和主线程的pid
和ppid
都是相同的,因为可以确定线程是在进程中运行的。 -
或者使用
ps
命令查看线程,因为查看的是线程,所以需要带上-L
选项。- 执行
ps -aL | head -1 && ps -aL | grep 文件名
![[Pasted image 20220210101809.png]] - 其中
LWP
(light weight process)是轻量级进程的ID。可知其实操作系统调度的时候,访问的是LWP
而不是PID
- Linux中,应用层的线程与内核的LWP是一一对应的,可以通过
pthread
库来操作应用层的线程,而LWP会随之产生
- 执行
2.3 线程标识
#include <pthread.h>
pthread_t pthread_self(void);
- 作用
- 返回原生线程库提供的用户级线程ID,与
pthread_create
第一个输出型参数的值是一样的
- 返回原生线程库提供的用户级线程ID,与
示例:
创建3个线程,分别在main
执行流中和线程执行任务的执行流中打印线程的ID
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <pthread.h>
void* Routine(void* arg)
{
char* msg = (char*)arg;
// 使用pthread_self()表示线程ID
printf("%s, ID:%x\n", msg, pthread_self());
return NULL;
}
int main()
{
pthread_t tid[3];
for (int i = 0; i < 3; i ++)
{
// 将不同的进程写入buffer中
char* buff = (char*)malloc(24);
sprintf(buff, "thread %d", i);
// 创建进程
pthread_create(&tid[i], NULL, Routine, buff);
// 使用tid表示线程ID
printf("%s, ID: %x\n", tid[i]);
}
// 让主线程不要退出
while (1)
{
sleep(1);
}
return 0;
}
注意这里的pthread_self()
和输出型参数tid
都是用户级别原生线程库提供的表示线程的ID,而使用ps -L
查看的LWP
是内核级别表示线程的ID。
用户级线程ID和内核级线程ID的联系是什么?即
thread_t
和LWP
的联系是什么?
用户级线程ID就是进程地址空间中的一个地址。
在Linux中有很多的进程,所以也就有很多的线程。而Linux中没有真正意义上的线程而是使用进程模拟出来,所以Linux系统内也就没有专门的数据结构来组织和管理这么多的线程。在Linux中操作系统只需要对LWP内核执行流进行管理。
而上面我们使用pthread
库中的函数来操作的线程是pthread
库中模拟出来的线程并且pthread
库自己去管理并组织的线程。
pthread
库是一个第三方动态库,也就是这个库本身就是一个文件。当程序运行的时候,文件加载到内存然后通过页表映射到虚拟地址的堆栈中间的共享区中。当动态库加载到共享区中并运行的时候,动态库中有一套完整地管理和组织线程的逻辑。
![[Pasted image 20220210152118.png]]
因此其实我们操作的线程是第三库模拟出的线程,我们操作也是模拟线程的从生到死。第三库库中的线程最后只需要将数据和代码交给操作系统管理的LWP执行流即可。
而因为动态库是在虚拟地址用户空间的,调度线程不需要进入内核区,只需要在用户区完成,所以线程的ID叫做用户级线程ID,而这个ID其实就是组织线程结构体的起始地址的首地址而已。
所以说用户级线程ID就是进程地址空间中的一个地址(虚拟地址)。
线程终止
线程终止有三种方法:
- 在线程执行任务的
routine
中return
表示线程退出- 在
main
函数中return
表示整个进程退出 - 使用
exit()
函数在任何地方调用表示整个进程退出
- 在
pthread_exit
#include <pthread.h>
void pthread_exit(void* retval);
- 作用
- 退出一个线程,并且可以执行线程的退出码,作用和
return
效果一样
- 退出一个线程,并且可以执行线程的退出码,作用和
需要注意:pthread_exit
或者return
返回的指针指向的内存单元一定要是全局的或者是malloc
出来的,而不能是函数栈上分配的,因为当其他线程得到这个返回指针时,线程函数已经退出了。
pthread_cancel
#include <pthread.h>
int pthread_cancel(pthread_t thread);
- 作用
- 指定取消一个线程号为
thread
的线程,相当于终止一个线程。一般用在main
线程取消指定的线程,导致线程执行的过程中突然被取消。如果取消成功,线程的退出码默认就是-1, 取消失败退出码默认是0
- 指定取消一个线程号为
- 参数
- 需要被终止的线程的线程号
- 返回值
- 取消成功返回0, 失败返回非0
示例:
创建3个线程,让主线程去主动取消1号2号线程,最后在main
中回收这3个线程,
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <pthread.h>
void* Routine(void*arg)
{
printf("thread has created\n");
// 线程退出码为666
return (void*)666;
}
int main()
{
pthread_t tid[3];
for (int i = 0; i < 3; i ++)
{
// 创建进程
pthread_create(&tid[i], NULL, Routine, NULL);
}
// 取消线程
pthread_cancel(tid[1