Thread
概述
进程创建
- Linux 进程创建: 通过 fork()系统调用创建进程
- Linux 用户级线程创建:通过 pthread 库中的 pthread_create()创建线程
- Linux 内核线程创建: 通过 kthread_create()
Linux 线程,也并非”轻量级进程”,在 Linux 看来线程是一种进程间共享资源的方式,线程可看做是跟其他进程共享资源的进程。
fork 流程图
进程/线程创建的方法 fork(),pthread_create(), 万物归一,最终在 linux 都是调用 do_fork 方法。 当然还有 vfork 其实也是一样的, 通过系统调用到 sys_vfork,然后再调用 do_fork 方法,该方法 现在很少使用,所以下图省略该方法。
fork 执行流程:
- 用户空间调用 fork()方法;
- 经过 syscall 陷入内核空间, 内核根据系统调用号找到相应的 sys_fork 系统调用;
- sys_fork()过程会在调用 do_fork(), 该方法参数有一个 flags 很重要, 代表的是父子进程之间需要共享的资源; 对于进程创建 flags=SIGCHLD, 即当子进程退出时向父进程发送 SIGCHLD 信号;
- do_fork(),会进行一些 check 过程,之后便是进入核心方法 copy_process.
flags 参数
进程与线程最大的区别在于资源是否共享,线程间共享的资源主要包括内存地址空间,文件系统,已打开文件,信号等信息, 如下图蓝色部分的 flags 便是线程创建过程所必需的参数。
fork 采用 Copy on Write 机制,父子进程共用同一块内存,只有当父进程或者子进程执行写操作时会拷贝一份新内存。 另外,创建进程也是有可能失败,比如进程个数达到系统上限(32768)或者系统可用内存不足。
接下来进一步介绍 fork 过程。
fork 源码分析
拷贝过程
创建进程
进程需要哪些东西
- 进程描述符,PCB,在 Linux 里面就是 task_struct 这个东西
- 每个进程要有自己的一段可执行代码
- 进程必须要有自己独立的内存空间
- 进程必须要有独立的内核堆栈
创建线程
在 Window 或 Solaris 等操作系统的内核提供了专门实现线程的机制,但 Linux 没有。Linux 创建线程的方式跟创建进程的方式很相似。比如要在一个进程里创建 n 个进程,Linux 仅仅创建 n 个进程并分配 n 个普通的进程描述符 task_struct。只不过这 n 个进程跟其他进程共享的资源跟普通进程不同罢了。总之,在 Linux 中,线程和进程的差别不大。
到了 linux 2.6, glibc 中有了一种新的 pthread 线程库 NPTL. NPTL 实现了前面提到的 POSIX 的全部 5 点要求. 但是, 实际上, 与其说是 NPTL 实现了, 不如说是 linux 内核实现了.
在 linux 2.6 中, 内核有了线程组的概念, task_struct 结构中增加了一个 tgid(thread group id)字段. 如果这个 task 是一个"主线程", 则它的 tgid 等于 pid, 否则 tgid 等于进程的 pid(即主线程的 pid),此外,每个线程有自己的 pid。在 clone 系统调用中, 传递 CLONE_THREAD 参数就可以把新进程的 tgid 设置为父进程的 tgid(否则新进程的 tgid 会设为其自身的 pid).类似的 XXid 在 task_struct 中还有两个:task->signal->pgid 保存进程组的打头进程的 pid、task->signal->session 保存会话打头进程的 pid。通过这两个 id 来关联进程组和会话。
有了 tgid, 内核或相关的 shell 程序就知道某个 tast_struct 是代表一个进程还是代表一个线程, 也就知道在什么时候该展现它们, 什么时候不该展现(比如在 ps 的时候, 线程就不要展现了).而 getpid(获取进程 ID)系统调用返回的也是 tast_struct 中的 tgid, 而 tast_struct 中的 pid 则由 gettid 系统调用来返回.在执行 ps 命令的时候不展现子线程,也是有一些问题的。比如程序 a.out 运行时,创建了一个线程。假设主线程的 pid 是 10001、子线程是 10002(它们的 tgid 都是 10001)。这时如果你 kill 10002,是可以把 10001 和 10002 这两个线程一起杀死的,尽管执行 ps 命令的时候根本看不到 10002 这个进程。如果你不知道 linux 线程背后的故事,肯定会觉得遇到灵异事件了。
为了应付"发送给进程的信号"和"发送给线程的信号", task_struct 里面维护了两套 signal_pending, 一套是线程组共享的, 一套是线程独有的.通过 kill 发送的信号被放在线程组共享的 signal_pending 中, 可以由任意一个线程来处理; 通过 pthread_kill 发送的信号(pthread_kill 是 pthread 库的接口, 对应的系统调用中 tkill)被放在线程独有的 signal_pending 中, 只能由本线程来处理.
当线程停止/继续, 或者是收到一个致命信号时, 内核会将处理动作施加到整个线程组中.
如何从进程的 PID 有效的导出她的描述符指针
Linux 进程状态
要弄懂 Linux 的进程状态,最简单的还是直接看系统源码。
进程状态的定义在fs/proc/array.c文件中。
|
|
同样,在include/linux/sche.h也做了定义
|
|
七种进程状态
可运行状态 R(TASK_RUNNING)
指正在被 CPU 运行或者就绪的状态。这样的进程被成为 runnning 进程。运行态的进程可以分为 3 种情况:内核运行态、用户运行态、就绪态。并不意味着进程一定在运行中,也可以在运行队列里。
只有在该状态的进程才可能在 CPU 上运行。而同一时刻可能有多个进程处于可执行状态,这些进程的 task_struct 结构(进程控制块)被放入对应 CPU 的可执行队列中(一个进程最多只能出现在一个 CPU 的可执行队列中)。进程调度器的任务就是从各个 CPU 的可执行队列中分别选择一个进程在该 CPU 上运行。
只要可执行队列不为空,其对应的 CPU 就不能偷懒,就要执行其中某个进程。一般称此时的 CPU“忙碌”。对应的,CPU“空闲”就是指其对应的可执行队列为空,以致于 CPU 无事可做。
有人问,为什么死循环程序会导致 CPU 占用高呢?因为死循环程序基本上总是处于 TASK_RUNNING 状态(进程处于可执行队列中)。除非一些非常极端情况(比如系统内存严重紧缺,导致进程的某些需要使用的页面被换出,并且在页面需要换入时又无法分配到内存……),否则这个进程不会睡眠。所以 CPU 的可执行队列总是不为空(至少有这么个进程存在),CPU 也就不会“空闲”。
很多操作系统教科书将正在 CPU 上执行的进程定义为 RUNNING 状态、而将可执行但是尚未被调度执行的进程定义为 READY 状态,这两种状态在 linux 下统一为 TASK_RUNNING 状态。
可中断的等待状态 S(TASK_INTERRUPTIBLE)
处于等待状态中的进程,也称可中断的睡眠状态,一旦被该进程等待的资源被释放,那么该进程就会进入运行状态。
处于这个状态的进程因为等待某事件的发生(比如等待 socket 连接、等待信号量),而被挂起。这些进程的 task_struct 结构被放入对应事件的等待队列中。当这些事件发生时(由外部中断触发、或由其他进程触发),对应的等待队列中的一个或多个进程将被唤醒。
通过 ps 命令我们会看到,一般情况下,进程列表中的绝大多数进程都处于 TASK_INTERRUPTIBLE 状态(除非机器的负载很高)。毕竟 CPU 就这么一两个,进程动辄几十上百个,如果不是绝大多数进程都在睡眠,CPU 又怎么响应得过来。
不可中断睡眠状态 D(TASK_UNINTERRUPTIBLE)
该状态的进程只能用 wake_up()函数唤醒。
与 TASK_INTERRUPTIBLE 状态类似,进程处于睡眠状态,但是此刻进程是不可中断的。不可中断,指的并不是 CPU 不响应外部硬件的中断,而是指进程不响应异步信号。
绝大多数情况下,进程处在睡眠状态时,总是应该能够响应异步信号的。否则你将惊奇的发现,kill -9 竟然杀不死一个正在睡眠的进程了!于是我们也很好理解,为什么 ps 命令看到的进程几乎不会出现 TASK_UNINTERRUPTIBLE 状态,而总是 TASK_INTERRUPTIBLE状态。
而 TASK_UNINTERRUPTIBLE状态存在的意义就在于,内核的某些处理流程是不能被打断的。如果响应异步信号,程序的执行流程中就会被插入一段用于处理异步信号的流程(这个插入的流程可能只存在于内核态,也可能延伸到用户态),于是原有的流程就被中断了。(参见《linux 内核异步中断浅析》)
在进程对某些硬件进行操作时(比如进程调用 read 系统调用对某个设备文件进行读操作,而 read 系统调用最终执行到对应设备驱动的代码,并与对应的物理设备进行交互),可能需要使用 TASK_UNINTERRUPTIBLE 状态对进程进行保护,以避免进程与设备交互的过程被打断,造成设备陷入不可控的状态。这种情况下的 TASK_UNINTERRUPTIBLE状态总是非常短暂的,通过 ps 命令基本上不可能捕捉到。
linux 系统中也存在容易捕捉的 TASK_UNINTERRUPTIBLE 状态。执行 vfork 系统调用后,父进程将进入 TASK_UNINTERRUPTIBLE 状态,直到子进程调用 exit 或 exec(参见《神奇的 vfork》)。
通过下面的代码就能得到处于 TASK_UNINTERRUPTIBLE 状态的进程:
|
|
编译运行,然后 ps 一下:
|
|
然后我们可以试验一下 TASK_UNINTERRUPTIBLE 状态的威力。不管 kill 还是 kill -9,这个 TASK_UNINTERRUPTIBLE状态的父进程依然屹立不倒。
暂停状态 T(TASK_STOPPED)
当进程收到信号 SIGSTOP、SIGTSTP、SIGTTIN 或 SIGTTOU 时就会进入暂停状态。可向其发送 SIGCONT 信号让进程转换到可运行状态。
向进程发送一个 SIGSTOP 信号,它就会因响应该信号而进入 TASK_STOPPED 状态(除非该进程本身处于 TASK_UNINTERRUPTIBLE 状态而不响应信号)。(SIGSTOP 与 SIGKILL 信号一样,是非常强制的。不允许用户进程通过 signal 系列的系统调用重新设置对应的信号处理函数。)
向进程发送一个 SIGCONT 信号,可以让其从 TASK_STOPPED状态恢复到 TASK_RUNNING状态。
跟踪状态 t (TASK_TRACED)
当进程正在被跟踪时,它处于 TASK_TRACED这个特殊的状态。“正在被跟踪”指的是进程暂停下来,等待跟踪它的进程对它进行操作。比如在 gdb 中对被跟踪的进程下一个断点,进程在断点处停下来的时候就处于 TASK_TRACED状态。而在其他时候,被跟踪的进程还是处于前面提到的那些状态。
对于进程本身来说, TASK_STOPPED和 TASK_TRACED状态很类似,都是表示进程暂停下来。
而 TASK_TRACED状态相当于在 TASK_STOPPED之上多了一层保护,处于 TASK_TRACED状态的进程不能响应 SIGCONT 信号而被唤醒。只能等到调试进程通过 ptrace 系统调用执行 PTRACE_CONT、PTRACE_DETACH等操作(通过 ptrace 系统调用的参数指定操作),或调试进程退出,被调试的进程才能恢复 TASK_RUNNING状态。
另外两个状态既可以存放在进程描述符的
state字段中,也可以放在exit_state状态中。从这两个字段的名称可以看出,只有当进程的执行被终止时,进程的状态才会变为这两种状态中的一种。
僵死状态 Z(EXIT_ZOMBIE)
进程在退出的过程中,处于 TASK_DEAD 状态。
在这个退出过程中,进程占有的所有资源将被回收,除了 task_struct结构(以及少数资源)以外。于是进程就只剩下 task_struct 这么个空壳,故称为僵尸。
之所以保留 task_struct,是因为 task_struct里面保存了进程的退出码、以及一些统计信息。而其父进程很可能会关心这些信息。比如在 shell 中,$?变量就保存了最后一个退出的前台进程的退出码,而这个退出码往往被作为 if 语句的判断条件。
当然,内核也可以将这些信息保存在别的地方,而将 task_struct结构释放掉,以节省一些空间。但是使用 task_struct结构更为方便,因为在内核中已经建立了从 pid 到 task_struct查找关系,还有进程间的父子关系。释放掉 task_struct,则需要建立一些新的数据结构,以便让父进程找到它的子进程的退出信息。
父进程可以通过 wait 系列的系统调用(如 wait4、waitid)来等待某个或某些子进程的退出,并获取它的退出信息。然后 wait 系列的系统调用会顺便将子进程的尸体(task_struct)也释放掉。
子进程在退出的过程中,内核会给其父进程发送一个信号,通知父进程来“收尸”。这个信号默认是 SIGCHLD,但是在通过 clone 系统调用创建子进程时,可以设置这个信号。
通过下面的代码能够制造一个 EXIT_ZOMBIE 状态的进程:
|
|
编译运行,然后 ps 一下:
$:~/test$ ps -ax | grep a/.out
10410 pts/0 S+ 0:00 ./a.out
10411 pts/0 Z+ 0:00 [a.out] <defunct>
10413 pts/1 S+ 0:00 grep a.out
只要父进程不退出,这个僵尸状态的子进程就一直存在。那么如果父进程退出了呢,谁又来给子进程“收尸”?
当进程退出的时候,会将它的所有子进程都托管给别的进程(使之成为别的进程的子进程)。托管给谁呢?可能是退出进程所在进程组的下一个进程(如果存在的话),或者是 1 号进程。所以每个进程、每时每刻都有父进程存在。除非它是 1 号进程。
1 号进程,pid 为 1 的进程,又称 init进程。
linux 系统启动后,第一个被创建的用户态进程就是 init 进程。它有两项使命:
- 执行系统初始化脚本,创建一系列的进程(它们都是 init 进程的子孙);
- 在一个死循环中等待其子进程的退出事件,并调用 waitid 系统调用来完成“收尸”工作;
init 进程不会被暂停、也不会被杀死(这是由内核来保证的)。它在等待子进程退出的过程中处于 TASK_INTERRUPTIBLE 状态,“收尸”过程中则处于 TASK_RUNNING 状态。
僵尸进程的危害:
- 上面说到僵尸进程是由于父进程没有读取到子进程的退出信息而产生的,那么我们是不是就可以理解为父进程一直以为处于僵尸状态的子进程是没有退出的。而进程是需要维护的,僵尸进程就会一直需要 PCB 对其进行维护;
- 浪费内存资源。如果僵尸进程一直没有退出,就会一直占用这块内存,就会导致内存资源的浪费;
- 内存泄漏。僵尸进程一直占用资源,但是却不使用,就可能会导致内存泄漏。
死亡状态 X (EXIT_DEAD)
而进程在退出过程中也可能不会保留它的 task_struct。比如这个进程是多线程程序中被 detach 过的进程(进程?线程?参见《linux 线程浅析》)。或者父进程通过设置 SIGCHLD 信号的 handler 为 SIG_IGN,显式的忽略了 SIGCHLD 信号。(这是 posix 的规定,尽管子进程的退出信号可以被设置为 SIGCHLD 以外的其他信号。)
此时,进程将被置于 EXIT_DEAD 退出状态,这意味着接下来的代码立即就会将该进程彻底释放。所以 EXIT_DEAD 状态是非常短暂的,几乎不可能通过 ps 命令捕捉到。
进程的初始状态
进程是通过 fork 系列的系统调用(fork、clone、vfork)来创建的,内核(或内核模块)也可以通过 kernel_thread 函数创建内核进程。这些创建子进程的函数本质上都完成了相同的功能——将调用进程复制一份,得到子进程。(可以通过选项参数来决定各种资源是共享、还是私有。)
那么既然调用进程处于 TASK_RUNNING 状态(否则,它若不是正在运行,又怎么进行调用?),则子进程默认也处于 TASK_RUNNING 状态。
另外,在系统调用调用 clone 和内核函数 kernel_thread 也接受 CLONE_STOPPED 选项,从而将子进程的初始状态置为 TASK_STOPPED。
进程状态变迁
进程自创建以后,状态可能发生一系列的变化,直到进程退出。而尽管进程状态有好几种,但是进程状态的变迁却只有两个方向——从 TASK_RUNNING 状态变为非 TASK_RUNNING 状态、或者从非 TASK_RUNNING 状态变为 TASK_RUNNING 状态。
也就是说,如果给一个 TASK_INTERRUPTIBLE 状态的进程发送 SIGKILL 信号,这个进程将先被唤醒(进入 TASK_RUNNING 状态),然后再响应 SIGKILL 信号而退出(变为 TASK_DEAD 状态)。并不会从 TASK_INTERRUPTIBLE 状态直接退出。
进程从非 TASK_RUNNING 状态变为 TASK_RUNNING 状态,是由别的进程(也可能是中断处理程序)执行唤醒操作来实现的。执行唤醒的进程设置被唤醒进程的状态为 TASK_RUNNING,然后将其 task_struct 结构加入到某个 CPU 的可执行队列中。于是被唤醒的进程将有机会被调度执行。
而进程从 TASK_RUNNING 状态变为非 TASK_RUNNING 状态,则有两种途径:
1、响应信号而进入 TASK_STOPED 状态、或 TASK_DEAD 状态;
2、执行系统调用主动进入 TASK_INTERRUPTIBLE 状态(如 nanosleep 系统调用)、或 TASK_DEAD 状态(如 exit 系统调用);或由于执行系统调用需要的资源得不到满足,而进入 TASK_INTERRUPTIBLE 状态或 TASK_UNINTERRUPTIBLE 状态(如 select 系统调用)。
显然,这两种情况都只能发生在进程正在 CPU 上执行的情况下。
孤儿进程
孤儿进程:一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。孤儿进程将被 init 进程(进程号为 1)所收养,并由 init 进程对它们完成状态收集工作。
僵尸进程:一个进程使用 fork 创建子进程,如果子进程退出,而父进程并没有调用 wait 或 waitpid 获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中。这种进程称之为僵死进程。
Linux 进程类别
虽然我们在区分 Linux 进程类别, 但是我还是想说 Linux 下只有一种类型的进程,那就是 task_struct,当然我也想说 linux 其实也没有线程的概念, 只是将那些与其他进程共享资源的进程称之为线程。
- 一个进程由于其运行空间的不同, 从而有内核线程和用户进程的区分, 内核线程运行在内核空间, 之所以称之为线程是因为它没有虚拟地址空间, 只能访问内核的代码和数据, 而用户进程则运行在用户空间, 但是可以通过中断, 系统调用等方式从用户态陷入内核态。
- 用户进程运行在用户空间上, 而一些通过共享资源实现的一组进程我们称之为线程组, Linux 下内核其实本质上没有线程的概念, Linux 下线程其实上是与其他进程共享某些资源的进程而已。但是我们习惯上还是称他们为线程或者轻量级进程
因此, Linux 上进程分 3 种,内核线程(或者叫核心进程)、用户进程、用户线程, 当然如果更严谨的,你也可以认为用户进程和用户线程都是用户进程。
关于轻量级进程这个概念, 其实并不等价于线程
不同的操作系统中依据其实现的不同, 轻量级进程其实是一个不一样的概念
详细信息参见 维基百科-LWP 轻量级进程
或者本人的另外一篇博客内核线程、轻量级进程、用户线程三种线程概念解惑(线程 ≠ 轻量级进程)
In computer operating systems, a light-weight process (LWP) is a means of achieving multitasking. In the traditional meaning of the term, as used in Unix System V and Solaris, a LWP runs in user space on top of a single kernel thread and shares its address space and system resources with other LWPs within the same process. Multiple user level threads, managed by a thread library, can be placed on top of one or many LWPs - allowing multitasking to be done at the user level, which can have some performance benefits.[1]
In some operating systems there is no separate LWP layer between kernel threads and user threads. This means that user threads are implemented directly on top of kernel threads. In those contexts, the term “light-weight process” typically refers to kernel threads and the term “threads” can refer to user threads.[2] On Linux, user threads are implemented by allowing certain processes to share resources, which sometimes leads to these processes to be called “light weight processes”.[3][4] Similarly, in SunOS version 4 onwards (prior to Solaris) “light weight process” referred to user threads.
进程与线程
进程是一个具有独立功能的程序关于某个数据集合的一次运行活动。它可以申请和拥有系统资源,是一个动态的概念,是一个活动的实体。它不只是程序的代码,还包括当前的活动,通过程序计数器的值和处理寄存器的内容来表示。进程是一个“执行中的程序”。程序是一个没有生命的实体,只有处理器赋予程序生命时,它才能成为一个活动的实体,我们称其为进程。
通常在一个进程中可以包含若干个线程,它们可以利用进程所拥有的资源。在引入线程的操作系统中,通常都是把进程作为分配资源的基本单位,而把线程作为独立运行和独立调度的基本单位。由于线程比进程更小,基本上不拥有系统资源,故对它的调度所付出的开销就会小得多,能更高效的提高系统内多个程序间并发执行的程度。
线程和进程的区别在于,子进程和父进程有不同的代码和数据空间,而多个线程则共享数据空间,每个线程有自己的执行堆栈和程序计数器为其执行上下文。多线程主要是为了节约 CPU 时间,发挥利用,根据具体情况而定。线程的运行中需要使用计算机的内存资源和 CPU。
进程是具有一定独立功能的程序关于某个数据集合上的一次运行活动,进程是系统进行资源分配和调度的一个独立单位。线程是进程的一个实体,是 CPU 调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。线程自己基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈),但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源。
线程与进程的区别归纳:
- 地址空间和其它资源:进程间相互独立,同一进程的各线程间共享。某进程内的线程在其它进程不可见。
- 通信:进程间通信 IPC,线程间可以直接读写进程数据段(如全局变量)来进行通信——需要进程同步和互斥手段的辅助,以保证数据的一致性。
- 调度和切换:线程上下文切换比进程上下文切换要快得多。
- 在多线程 OS 中,进程不是一个可执行的实体。
内核线程
内核线程就是内核的分身,一个分身可以处理一件特定事情。这在处理异步事件如异步 IO 时特别有用。内核线程的使用是廉价的,唯一使用的资源就是内核栈和上下文切换时保存寄存器的空间。支持多线程的内核叫做多线程内核(Multi-Threads kernel )。
- 内核线程只运行在内核态,不受用户态上下文的拖累。
- 处理器竞争:可以在全系统范围内竞争处理器资源;
- 使用资源:唯一使用的资源是内核栈和上下文切换时保持寄存器的空间
- 调度:调度的开销可能和进程自身差不多昂贵
- 同步效率:资源的同步和数据共享比整个进程的数据同步和共享要低一些。
linux 进程的创建流程
线程机制式现代编程技术中常用的一种抽象概念。该机制提供了同一个程序内共享内存地址空间,打开文件和资源的一组线程。
进程的复制 fork 和加载 execve
我们在 Linux 下进行进行编程,往往都是通过 fork 出来一个新的程序,fork 从化字面意义上理解就是说”分叉”, 这其实就意味着我们的 fork 进程并不是真正从无到有被创建出来的。
一个进程,包括代码、数据和分配给进程的资源,它其实是从现有的进程(父进程)复制出的一个副本(子进程),fork()函数通过系统调用创建一个与原来进程几乎完全相同的进程,也就是两个进程可以做完全相同的事,然后如果我们通过 execve 为子进程加载新的应用程序后,那么新的进程将开始执行新的应用
简单来说,新的进程是通过 fork 和 execve 创建的,首先通过 fork 从父进程分叉出一个基本一致的副本,然后通过 execve 来加载新的应用程序镜像
- fork 生成当前进程的的一个相同副本,该副本成为子进程
原进程(父进程)的所有资源都以适当的方法复制给新的进程(子进程)。因此该系统调用之后,原来的进程就有了两个独立的实例,这两个实例的联系包括:同一组打开文件, 同样的工作目录, 进程虚拟空间(内存)中同样的数据(当然两个进程各有一份副本, 也就是说他们的虚拟地址相同, 但是所对应的物理地址不同)等等。
- execve 从一个可执行的二进制程序镜像加载应用程序, 来代替当前运行的进程
换句话说, 加载了一个新的应用程序。因此 execv 并不是创建新进程
所以我们在 linux 要创建一个应用程序的时候,其实执行的操作就是
- 首先使用 fork 复制一个旧的进程
- 然后调用 execve 在为新的进程加载一个新的应用程序
写时复制技术
有人认为这样大批量的复制会导致执行效率过低。其实在复制过程中,linux 采用了写时复制的策略。
写入时复制(Copy-on-write)是一个被使用在程式设计领域的最佳化策略。其基础的观念是,如果有多个呼叫者(callers)同时要求相同资源,他们会共同取得相同的指标指向相同的资源,直到某个呼叫者(caller)尝试修改资源时,系统才会真正复制一个副本(private copy)给该呼叫者,以避免被修改的资源被直接察觉到,这过程对其他的呼叫只都是通透的(transparently)。此作法主要的优点是如果呼叫者并没有修改该资源,就不会有副本(private copy)被建立。
第一代 Unix 系统实现了一种傻瓜式的进程创建:当发出 fork()系统调用时,内核原样复制父进程的整个地址空间并把复制的那一份分配给子进程。这种行为是非常耗时的,这种创建地址空间的方法涉及许多内存访问,消耗许多 CPU 周期,并且完全破坏了高速缓存中的内容。在大多数情况下,这样做常常是毫无意义的,因为许多子进程通过装入一个新的程序开始它们的执行,这样就完全丢弃了所继承的地址空间。
现在的 Linux 内核采用一种更为有效的方法,称之为写时复制(Copy On Write,COW)。这种思想相当简单:父进程和子进程共享页帧而不是复制页帧。然而,只要页帧被共享,它们就不能被修改,即页帧被保护。无论父进程还是子进程何时试图写一个共享的页帧,就产生一个异常,这时内核就把这个页复制到一个新的页帧中并标记为可写。原来的页帧仍然是写保护的:当其他进程试图写入时,内核检查写进程是否是这个页帧的唯一属主,如果是,就把这个页帧标记为对这个进程是可写的。
当进程 A 使用系统调用 fork 创建一个子进程 B 时,由于子进程 B 实际上是父进程 A 的一个拷贝,
因此会拥有与父进程相同的物理页面.为了节约内存和加快创建速度的目标,fork()函数会让子进程 B 以只读方式共享父进程 A 的物理页面.同时将父进程 A 对这些物理页面的访问权限也设成只读.
这样,当父进程 A 或子进程 B 任何一方对这些已共享的物理页面执行写操作时,都会产生页面出错异常(page_fault int14)中断,此时 CPU 会执行系统提供的异常处理函数 do_wp_page()来解决这个异常.
do_wp_page()会对这块导致写入异常中断的物理页面进行取消共享操作,为写进程复制一新的物理页面,使父进程 A 和子进程 B 各自拥有一块内容相同的物理页面.最后,从异常处理函数中返回时,CPU 就会重新执行刚才导致异常的写入操作指令,使进程继续执行下去.
一个进程调用 fork()函数后,系统先给新的进程分配资源,例如存储数据和代码的空间。然后把原来的进程的所有值都复制到新的新进程中,只有少数值与原来的进程的值(比如 PID)不同。相当于克隆了一个自己。
关于进程创建的
不同操作系统线程的实现机制
专门线程支持的系统-LWP 机制
线程更好的支持了并发程序设计技术, 在多处理器系统上, 他能保证真正的并行处理。Microsoft Windows 或是 Sun Solaris 等操作系统都对线程进行了支持。
这些系统中都在内核中提供了专门支持线程的机制, Unix System V 和 Sun Solaris 将线程称作为轻量级进程(LWP-Light-weight process),在这些系统中, 相比较重量级进程, 线程被抽象成一种耗费较少资源, 运行迅速的执行单元。
Linux 下线程的实现机制
但是 Linux 实现线程的机制非常独特。从内核的角度来说, 他并没有线程这个概念。Linux 把所有的进程都当做进程来实现。内核中并没有准备特别的调度算法或者定义特别的数据结构来表示线程。相反, 线程仅仅被视为一个与其他进程共享某些资源的进程。每个线程都拥有唯一隶属于自己的 task_struct, 所以在内核看来, 它看起来就像式一个普通的进程(只是线程和同组的其他进程共享某些资源)
在之前Linux 进程描述符 task_struct 结构体详解–Linux 进程的管理与调度(一)和Linux 进程 ID 号–Linux 进程的管理与调度(三)中讲解进程的 pid 号的时候我们就提到了, 进程 task_struct 中pid 存储的是内核对该进程的唯一标示, 即对进程则标示进程号, 对线程来说就是其线程号, 那么对于线程来说一个线程组所有线程与领头线程具有相同的进程号,存入 tgid 字段
因此getpid()返回当前进程的进程号,返回的应该是 tgid 值而不是 pid 的值, 对于用户空间来说同组的线程拥有相同进程号即 tpid, 而对于内核来说, 某种成都上来说不存在线程的概念, 那么 pid 就是内核唯一区分每个进程的标示。
正是 linux 下组管理, 写时复制等这些巧妙的实现方式
- linux 下进程或者线程的创建开销很小
- 既然不管是线程或者进程内核都是不加区分的,一组共享地址空间或者资源的线程可以组成一个线程组, 那么其他进程即使不共享资源也可以组成进程组, 甚至来说一组进程组也可以组成会话组, 进程组可以简化向所有组内进程发送信号的操作, 一组会话也更能适应多道程序环境
实现机制的区别
总而言之, Linux 中线程与专门线程支持系统是完全不同的
Unix System V 和 Sun Solaris 将用户线程称作为轻量级进程(LWP-Light-weight process), 相比较重量级进程, 线程被抽象成一种耗费较少资源, 运行迅速的执行单元。
而对于 linux 来说, 用户线程只是一种进程间共享资源的手段, 相比较其他系统的进程来说, linux 系统的进程本身已经很轻量级了
举个例子来说, 假如我们有一个包括了四个线程的进程,
在提供专门线程支持的系统中, 通常会有一个包含只想四个不同线程的指针的进程描述符。该描述符复制描述像地址空间, 打开的文件这样的共享资源。线程本身再去描述它独占的资源。
相反, Linux 仅仅创建了四个进程, 并分配四个普通的 task_struct 结构, 然后建立这四个进程时制定他们共享某些资源。
内核线程
Linux 内核可以看作一个服务进程(管理软硬件资源,响应用户进程的种种合理以及不合理的请求)。内核需要多个执行流并行,为了防止可能的阻塞,多线程化是必要的。
内核线程就是内核的分身,一个分身可以处理一件特定事情。Linux 内核使用内核线程来将内核分成几个功能模块,像 kswapd、kflushd 等,这在处理异步事件如异步 IO 时特别有用。内核线程的使用是廉价的,唯一使用的资源就是内核栈和上下文切换时保存寄存器的空间。支持多线程的内核叫做多线程内核(Multi-Threads kernel )。内核线程的调度由内核负责,一个内核线程处于阻塞状态时不影响其他的内核线程,因为其是调度的基本单位。这与用户线程是不一样的。
内核线程只运行在内核态,不受用户态上下文的拖累。
- 处理器竞争:可以在全系统范围内竞争处理器资源;
- 使用资源:唯一使用的资源是内核栈和上下文切换时保持寄存器的空间
- 调度:调度的开销可能和进程自身差不多昂贵
- 同步效率:资源的同步和数据共享比整个进程的数据同步和共享要低一些。
内核线程与普通进程的异同
- 跟普通进程一样,内核线程也有优先级和被调度。 当和用户进程拥有相同的 static_prio 时,内核线程有机会得到更多的 cpu 资源
- 内核线程的 bug 直接影响内核,很容易搞死整个系统, 但是用户进程处在内核的管理下,其 bug 最严重的情况也只会把自己整崩溃
- 内核线程没有自己的地址空间,所以它们的”current->mm”都是空的;
- 内核线程只能在内核空间操作,不能与用户空间交互;
内核线程不需要访问用户空间内存,这是再好不过了。所以内核线程的 task_struct 的 mm 域为空
但是刚才说过,内核线程还有核心堆栈,没有 mm 怎么访问它的核心堆栈呢?这个核心堆栈跟 task_struct 的 thread_info 共享 8k 的空间,所以不用 mm 描述。
但是内核线程总要访问内核空间的其他内核啊,没有 mm 域毕竟是不行的。 所以内核线程被调用时, 内核会将其 task_strcut 的 active_mm 指向前一个被调度出的进程的 mm 域, 在需要的时候,内核线程可以使用前一个进程的内存描述符。
因为内核线程不访问用户空间,只操作内核空间内存,而所有进程的内核空间都是一样的。这样就省下了一个 mm 域的内存。
内核线程创建
在内核中,有两种方法可以生成内核线程,一种是使用 kernel_thread()接口,另一种是用 kthread_create()接口
kernel_thread
先说 kernel_thread 接口,使用该接口创建的线程,必须在该线程中调用 daemonize()函数,这是因为只有当线程的父进程指向”Kthreadd”时,该线程才算是内核线程,而恰好 daemonize()函数主要工作便是将该线程的父进程改成“kthreadd”内核线程;默认情况下,调用 deamonize()后,会阻塞所有信号,如果想操作某个信号可以调用 allow_signal()函数。
|
|
kthread_create
而 kthread_create 接口,则是标准的内核线程创建接口,只须调用该接口便可创建内核线程;默认创建的线程是存于不可运行的状态,所以需要在父进程中通过调用 wake_up_process()函数来启动该线程。
|
|
线程创建后,不会马上运行,而是需要将 kthread_create() 返回的 task_struct 指针传给 wake_up_process(),然后通过此函数运行线程。
kthread_run
当然,还有一个创建并启动线程的函数:kthread_run
|
|
线程一旦启动起来后,会一直运行,除非该线程主动调用 do_exit 函数,或者其他的进程调用 kthread_stop 函数,结束线程的运行。
|
|
因为线程也是进程,所以其结构体也是使用进程的结构体”struct task_struct”。
内核线程的退出
当线程执行到函数末尾时会自动调用内核中 do_exit()函数来退出或其他线程调用 kthread_stop()来指定线程退出。
总结
Linux 使用 task_struct 来描述进程和线程
- 一个进程由于其运行空间的不同, 从而有内核线程和用户进程的区分, 内核线程运行在内核空间, 之所以称之为线程是因为它没有虚拟地址空间, 只能访问内核的代码和数据, 而用户进程则运行在用户空间, 不能直接访问内核的数据但是可以通过中断, 系统调用等方式从用户态陷入内核态,但是内核态只是进程的一种状态, 与内核线程有本质区别
- 用户进程运行在用户空间上, 而一些通过共享资源实现的一组进程我们称之为线程组, Linux 下内核其实本质上没有线程的概念, Linux 下线程其实上是与其他进程共享某些资源的进程而已。但是我们习惯上还是称他们为线程或者轻量级进程
因此, Linux 上进程分 3 种,内核线程(或者叫核心进程)、用户进程、用户线程, 当然如果更严谨的,你也可以认为用户进程和用户线程都是用户进程。
内核线程拥有 进程描述符、PID、进程正文段、核心堆栈
用户进程拥有 进程描述符、PID、进程正文段、核心堆栈 、用户空间的数据段和堆栈
用户线程拥有 进程描述符、PID、进程正文段、核心堆栈,同父进程共享用户空间的数据段和堆栈
用户线程也可以通过 exec 函数族拥有自己的用户空间的数据段和堆栈,成为用户进程。
Reference
- https://www.kancloud.cn/kancloud/understanding-linux-processes/52200
- https://idea.popcount.org/2012-12-11-linux-process-states/
- http://www.linuxso.com/linuxpeixun/11843.html
- https://www.cnblogs.com/Anker/p/3271773.html
Reference
-
No backlinks found.