linux进程的管理与调度 --- 基础概念
进程的管理与调度是所有操作系统的核心功能。从内核的角度来看,进程是内核分配资源(CPU,Memory)的重要单元,是计算机用来管理这些资源的一种抽象。
进程状态
1、TASK_RUNNING
表示进程要么正在执行,要么准备执行,等待cpu时间片的调度
2、TASK_INTERRUPTIBLE
表示进程被挂起(睡眠),直到某个条件成立触发CPU中断或者发送信号唤醒该进程,将其状态改成TASK_RUNNING,比如某个TASK_RUNNING的进程需要读取文件,发起系统调用从用户态进入内核态,内核将其状态改成TASK_INTERRUPTIBLE,然后调用磁盘驱动程序读取文件,CPU执行其他任务;待磁盘读取文件完毕,磁盘发送CPU中断信号,CPU将读取的文件内容传给进程,进程由内核态切换到用户态,处理文件内容。一般情况下,进程列表中的绝大多数进程都处于TASK_INTERRUPTIBLE状态,除非机器的负载很高。
3、TASK_UNINTERRUPTIBLE
与TASK_INTERRUPTIBLE类似,区别是不能被外部信号唤醒,只能通过CPU中断唤醒。该状态总是非常短暂的,通过ps命令基本上不可能捕捉,主要用于避免内核某些处理过程被中断,如进程与设备交互的过程,中断会造成设备陷入不可控的状态。
4、TASK_STOPPED
表示进程的执行已停止,向进程发送一个SIGSTOP,SIGTSTP,SIGTTIN,SIGTTOU信号,它就会因响应该信号而进入TASK_STOPPED状态,向进程发送一个SIGCONT信号,可以让其恢复到TASK_RUNNING状态。
5、TASK_TRACED
表示进程的执行已停止,等待跟踪它的进程对它进行操作,比如在gdb中对被跟踪的进程下一个断点,进程在断点处停下来的时候就处于TASK_TRACED状态。处于TASK_TRACED状态的进程不能响应SIGCONT信号被唤醒,只能等到调试进程通过ptrace系统调用执行PTRACE_CONT、PTRACE_DETACH等操作或调试进程退出,被调试的进程才能恢复TASK_RUNNING状态。
6、EXIT_ZOMBIE
表示进程已终止,正等待其父进程执行wait类系统调用收集关于它的一些统计信息如退出码,内核此时无法回收该进程的进程描述符。如果父进程未执行wait类系统调用并退出了,子进程会转交给上一级的父进程,直到最终的init进程,由上一级父进程执行wait类系统调用。
7、EXIT_DEAD
表示进程已终止,父进程已经执行wait类系统调用,进程即将被内核删除,该状态非常短暂。
Linux Kernel 2.6.25 引入了一种新的进程睡眠状态,TASK_KILLABLE:当进程处于这种可以终止的新睡眠状态中,它的运行原理类似于 TASK_UNINTERRUPTIBLE,只不过可以响应致命信号。
用户态 / 内核态切换
内核态:
当一个任务(进程)执行系统调用而陷入内核代码中执行时,我们就称进程处于内核运行态。此时处理器处于特权级最高的(0级)内核代码中执行。当进程处于内核态时,执行的内核代码会使用当前进程的内核栈。
用户态:
每个进程都有自己的内核栈。当进程在执行用户自己的代码时,则称其处于用户运行态。即此时处理器在特权级最低的(3级)用户代码中运行。
从用户态到内核态的转换发生在:
- 发生系统调用
- CPU执行异常
- 外围设备发来中断请求
进程是资源分配的基本单位, 因此进程之间切换时,需要保存、装载各种状态数据等资源, 所需的代价较高。线程是操作系统调度的基本单位,同一个进程内的线程共享操作系统给该进程分配的资源,共享一个地址空间,不需要特别麻烦的切换页表、刷新TLB,只需要将寄存器刷新一遍就行,比进程切换开销少。
调度是在内核态运行的,用户态切换到内核态通过中断完成,操作系统切换线程上下文的步骤如下所示:
1)保留用户态现场(上下文、寄存器、用户栈等)
2)复制用户态参数,用户栈切到内核栈,进入内核态
3)代码安全检查(内核不信任用户态代码)
4)执行内核态代码,如切换线程
5)复制内核态代码执行结果,回到用户态
6)恢复用户态现场(上下文、寄存器、用户栈等)
协程
协程是一种用户态线程,它比线程更加轻量并且协程对于操作系统是并不可见的,同一时刻一个CPU只会执行一个协程。
每个线程都有自己的TCB都有自己的堆栈,创建一个线程是有很大的花销的
进程/线程创建
进程创建
进程通过 fork 和 exec 调用来进行创建。
fork 通过拷贝当前进程创建一个子进程,区别在于 PID
exec 负责读取可执行文件,并载入地址空间开始运行
对于 fork 这种方式,Linux 出于效率的考虑,并没有在实现的时候直接复制所有信息, 而是使用了一种叫做写时拷贝(copy-on-write,COW)。内核并不复制整个进程地址空间,只有在需要写入的时候,数据才会复制。
fork 是通过 clone() 系统调用来实现的,然后 clone 又去调用 do_fork (kernel/fork.c)
在 do_fork 中调用 copy_process 函数创建一个新的 task_struct 描述符
fork() --> clone() --> do_fork() --> copy_process() --> dup_task_struct()
线程创建
线程其实和进程共享了一些资源,内核其实把线程当成是一个进程来对待(包括调度,也用 task_struct 来描述),内核看起来,就像是普通的进程一样(一些标志不一样)。
创建线程的时候,和创建进程类似,只不过在调用 clone 的时候,传入了一些参数标志:
clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND,0);
这个和普通的 差不多,只是共享了地址空间、文件系统资源、文件描述符、信号处理程序。
内核线程创建
内核进程需要在后台执行一些操作,这种任务可以通过内核线程(kernel thread)完成。它与普通的线程的区别在于,没有独立地址空间。也就是 task_struct 的 mm 指针为 NULL,他们只在内核空间运行,能被调度和抢占。比如,flush,ksoftirqd等等。
对于内核线程没有自己的独立地址空间的这个说法,具体的理解为:
用户空间:不同进程的虚拟地址操作虽然仍是统一的,但物理地址却因为独立地址空间的缘故而映射不一致,以至于影响不到其他进程的资源。独立的地址空间意味着数据修改的彼此独立性,即严防不同进程之间干扰。这符合“进程是系统资源分配的最小单位”的要求。
内核空间:所有线程虚拟地址对应的物理地址都是一样的, 所以说是共享。
打个比方:
A,B为用户进程,C为内核线程。那么,A,B进程的0-3G的虚拟地址空间是相互独立的,而 C 并没有独立属于自己的地址空间,它的地址空间在 3G~4G 的地方,这部分是 A,B,C都共享的,所以 C 不能称之为有独立的地址空间。
内核线程通过函数 kthread_create 来创建:
#define kthread_create(threadfn, data, namefmt, arg...) \ kthread_create_on_node(threadfn, data, NUMA_NO_NODE, namefmt, ##arg) struct task_struct *kthread_create_on_node(int (*threadfn)(void *data), void *data, int node, const char namefmt[], ...) { struct task_struct *task; va_list args; va_start(args, namefmt); task = __kthread_create_on_node(threadfn, data, node, namefmt, args); va_end(args); return task; }
其中 threadfn 是内核线程的执行函数,data 是传递给 threadfn 的入参,node 是内存节点(NUMA 体系),namefmt 是线程名称。通过这个 API 创建的内核线程不会马上工作,出于一个待唤醒的状态,如果希望其工作,还需调用 wake_up_process 唤醒它才行。
这里内核实现了一个函数 kthread_run 来执行上述两条命令,即调用了这个 kthread_run 函数,内核线程便会立马运行了,它的实现也就是顺序 call 了 kthread_create 和 wake_up_process 。
使用 kthread_stop 来退出。