比如我要做一个视频播放器,就需要实现三个功能:
① 从磁盘读取视频数据
② 对读取到的视频数据进行解码
③ 对解码的数据进行播放
那么播放一会就需要等待数据从磁盘加载(读磁盘很慢,会使得这个进程阻塞,CPU空置),然后通过CPU解码,就会一卡一卡的
进程1读磁盘内容,然后传递给进程2解码,再传递给进程3播放,这样就产生了两个问题:
① 线程直接共享进程的所有资源 (比如 mm_struct),所以线程就变轻了,创建线程比创建进程要快到 10 ~ 100 倍
② 线程之间共享相同的地址空间 (mm_struct),这样利于线程之间数据高效的传输
③ 可以在一个进程中创建多个线程,实现程序的并发执行
什么是线程:进程中的一条执行流(函数调用链),用于执行不同路径的代码指令,每个进程一开始都有一个主线程
因此,进程可视为由两部分组成:资源平台(地址空间、磁盘、网络资源等)、线程
线程共享mm_struct,所以其执行的代码指令是存放在进程地址空间的代码段中
前文说了线程就是一条函数调用链,所以每个线程需要有自己私有的线程栈,存放在当前进程的堆中
而主线程(如main函数)的栈则使用进程的栈
线程栈从高地址向低地址生长
全局变量(读/写数据段)
线程私有变量
线程创建代码实例 pthread_create():
线程私有数据设置:
由于一个进程会有多个线程栈,可以用两个链表来管理这些线程栈:
pthread创建线程是由内核态和用户态合作实现的,也就是先在用户态创建一个线程(pthread实例),然后在切换到内核态再创建一个线程(task_struct实例):
用户态(创建一个用户态的线程):
clone()
系统调用:将子线程要执行的函数代码起始指令位置、参数写入寄存器(很重要) => 到此为止都是主线程在执行内核态(创建一个内核态的线程管理用户态的线程):
将主线程的寄存器信息保存到主线程内核栈中
调用do_fork()(创建进程也是用的do_fork(),所以进程线程的创建都差不太多)
维护线程的亲缘关系,主要是维护线程和所属进程的关系
将task_stuct加入链表队列
在内核的角度,线程和进程的区别并不大,只是进程需要多一份资源管理
Tip:
所以,为减少CPU的上下文切换,可以建立线程池,当线程执行完后,把线程还给线程池(在用户态阻塞),而非操作系统,后续再重用这个线程,同时,设置最大线程数量,防止内存不足
用户态的栈就是父进程的栈,栈顶指针也指向父进程的栈,指令指针也是指向父进程的代码
那么切回到用户态将会进入主线程
用户态的线程栈就是创建线程A的栈,栈顶指针也指向线程A的栈,指令指针也是指向线程A的代码
然后执行start_tread(),执行线程函数
但其实在内核拿到子线程CPU上下文,准备返回用户态的那一刻,主线程和子线程进行了一次线程切换参考链接,主线程的CPU上下文信息写入了其内核栈,等下次调度主线程时,就可以顺利运行了
PCB与TCB:
操作系统每创建一个进程,都会在内核态创建一个进程管理器PCB: Process Control Block
,存入进程表
操作系统每创建一个线程,都会创建一个线程管理器TCB: Thread Control Block
(如果是创建用户级线程,则TCB必须存放在用户态),存入线程表
用户级线程:由一些应用程序中的线程库来实现,应用程序可以调用线程库的 API 来完成线程的创建、线程的结束等操作
用户级线程优点:
缺点:
内核级线程:在内核空间实现的线程,由操作系统管理的线程;内核级线程管理的所有工作都是由操作系统内核完成,比如内核线程的创建、结束、是否占用 CPU 等都是由操作系统内核来管理。
在支持内核线程的操作系统中,由内核来维护进程和线程的上下文信息 (PCB 和 TCB),一个进程的 PCB 会管理这个进程中所有线程的 TCB,当一个线程阻塞,那么内核可以选择另一个线程继续运行。=> 比如Linux
在Linux中,pthread_create会创建一个用户级线程 + 一个内核级线程,pthread_create创建一个TCB,内核会创建一个内核级线程(task_struct)来管理这个用户态线程
Tip: 这里的内核级线程也叫轻量级进程LWP
内核级线程的优点:
缺点:
在一个进程中,如果某个内核级线程因为发起系统调用而被阻塞,并不会影响其他内核线程的运行。因为内核级线程是被操作系统管理,受操作系统调度的
因为内核级线程是调度单位,所以操作系统将整个时间片是分配给线程的,多线程的进程获得更多的 CPU 时间
不管怎样,线程的实现都需要用户态和内核态的相互配合,因此产生了如下几种关系:
线程的TCB存放在用户态,通过一个task_struct访问系统资源,也就是用户级线程,这种线程模式线程切换快,开销小
线程的TCB存放在内核态,也就是内核态线程,如上文讲的pthread, 这种线程模式并发能力强
比如Go中的协程,需要根据自定义的调度器进行切换
不管是创建进程(fork)还是创建线程(clone),都需要在内核调用do_fork()
而内核线程也可以通过kernel_thread()调用dofork()来创建
与内核级线程不同,内核线程不能访问用户态内存空间
当进程处于内核态时,指向内核态的地址空间active_mm=mm;当进程处于用户态时,指向用户态的地址空间;active_mm=init_mm
而内核线程的mm=null,因此不能访用户态虚拟地址空间
Tip:1号进程如何从内核进程转变为普通进程?
在工作中,线程池是肯定会遇到的,会经常遇到线程的状态的变化,一般线程的状态为:创建、就绪、运行、阻塞、结束
还是一个状态很重要:挂起
阻塞挂起:当一个线程处于阻塞时,而其他运行中的线程需要的内核又很多,系统会把这个阻塞线程的内存交换到磁盘,即使等待的事件到达了,也只能转变为就绪挂起状态
阻塞解挂:当磁盘中的数据加载到内存后,线程的状态就从阻塞挂起变成了阻塞
同理,就绪状态的线程也可能会挂起
而处于运行中的线程,如果也因为内存不够,就会转变为就绪挂起状态
正常来说,一个线程需要进行IO操作,此时将会阻塞,等待IO操作完成后,再继续执行
但现在,在阻塞的时候,其他线程发了一个kill- 9的命令,如果是可中断的阻塞,需要响应这个信号,杀死自己;而如果是不可中断,则不会响应这个信号
不可中断的阻塞是个很危险的事情,一旦 I/O 操作因为特殊原因不能完成,这个时候,谁也叫不醒这个进程了;所以一般只有内核线程才会设置这个状态,比如执行磁盘IO(DMA搬运数据被打断可能会产生严重问题)时