0%

操作系统之进程与线程管理

任务、线程、进程三者关系

任务是一个抽象的概念,即指软件完成的一个活动;而线程则是完成任务所需的动作;进程则指的是完成此动作所需资源的统称;关于三者的关系,有一个形象的比喻:

  • 任务 = 送货
  • 线程 = 开送货车
  • 系统调度 = 决定合适开哪部送货车
  • 进程 = 道路 + 加油站 + 送货车 + 修车厂
进程
进程的概念

操作系统为进程提供两种虚拟机制。虚拟处理器让进程觉得自己在独享CPU。虚拟内存让进程在分配和管理内存的时候觉得自己拥有全部内存资源。

进程就是处于执行期的程序和一组相关的系统资源的总称。这些资源包括,打开的文件描述符、挂起的信号、内核内部数据、处理器状态、映射了的内存地址空间、所有执行的线程以及存放全局变量的数据段。它的两个基本元素就是程序代码(可能被执行相同程序的其他进程共享)和与代码相关联的数据集。

进程最少必须包括一个或一组被执行的程序,而与这些程序相关联的是局部变量、全局变量和任何已定义常量的数据单元。因此,一个进程至少应有足够的内存空间来保存其程序和数据;此外,程序的执行通常涉及用于跟踪过程调用和过程间参数传递的栈帧。最后,还有与每个进程相关的许多属性,以便操作系统控制该进程。通常,属性集称为进程描述符程序、数据、栈帧和属性的集合称为进程映像。下图表示了进程映像在虚存中的结构。

image-20210319113140920

进程描述符

linux内核的观点看,进程又被称为任务,内核将所有进程组织在叫做任务队列的一个双向循环链表中。链表中的每一项是类型为task_struct的结构体(定义在<linux/sched.h>文件中),称为进程描述符或进程控制块,它被用来定义和描述一个完整的进程。

进程描述符包含有一个具体进程的所有信息,主要包括:

  • 进程标识信息
    • 标识符:与进程相关的唯一标识符,用来区分其他进程。每个进程都有唯一的一个进程标识符PID,以及用户标识符UID和组标识符GID。组标识符用于给一组进程指定资源访问特权。
  • 进程状态信息
    • 程序计数器:程序中即将执行的下一条指令的地址。
    • 栈指针:用于指向保存参数和过程调用或系统调用的地址的栈帧的栈顶指针。
    • 上下文数据:进程执行时处理器的寄存器组中的数据。包括控制,状态寄存器等。
  • 进程控制信息
    • 状态:表示进程的执行状态,有执行态、就绪态、停止态、阻塞态和僵死态。
    • 调度信息:Linux调度进程所需要的信息。一个进程可能是普通的或实时的,并具有优先级。实时进程在普通进程前调度,且在每类中使用相关的优先级。一个计数器会记录允许进程执行的时间量。
    • 等待事件信息:进程继续执行前等待的事件标识。
    • 地址空间信息:包括程序代码和进程相关数据的指针,以及与其他进程共享内存块的指针。
    • 时间和计时器:包括进程创建的时刻和进程所消耗的处理器时间总量。一个进程可能还有一个或多个间隔计时器,进程通过系统调用来定义间隔计时器,计时器期满时,会给进程发送一个信号。计时器可以只用一次或周期性地使用。
    • 链接:每个进程都有一个到其父进程的链接及到其兄弟进程(与它有相同的父进程)的链接,以及到其所有子进程的链接。
    • 文件系统:包括指向被该进程打开的任何文件的指针和指向该进程当前目录与根目录的指针。

Linux通过slab分配器动态地给task_struct的结构体分配内存空间,这样能达到对象复用(通过预先分配和重复使用task_struct,可以避免动态分配和释放带来的资源消耗)和缓存着色的目的。除此之外,还需要在内核栈的栈底创建一个类型为thread_info的结构体(定义在<asm/thread_info.h>文件中),结构中的task域存放有指向该任务的task_struct的指针。

对于Linux系统,进程描述符中的state域描述了当前进程的状态,也就是其行为特征,共分为5种:

  1. TASK_RUNNING

    表示进程是可执行的;要么是在运行队列中等待被调度执行(就绪态),要么是正在执行(运行态)。

  2. TASK_INIERRUPTIBLE

    进程正在被阻塞(阻塞态),等待某些条件的满足。一旦条件满足或者接收到信号而提前被唤醒,会从当前状态转为TASK_RUNNING状态(就绪态)。

  3. TASK_UNINIERRUPTIBLE

    TASK_INIERRUPTIBLE状态相同,除了不响应任何信号。也就是执行ps命令后看到进程被标记为D状态的进程,不能通过发送SIGKILL信号杀死处于此状态的进程。比如内核态进程在等待某项资源,这时候是不允许被打断的,否则可能导致系统进入未知状态,或者引发严重的系统异常,所以对于这种操作,定义D状态进行保护。使其在完成本次操作之前,不可以被任何异步状态打断。

  4. _TASK_TRACED

    表示此进程正在被其它进程跟踪,例如通过ptrace对调试程序进行跟踪。

  5. _TASK_STOPPED

    进程处于终止状态(停止态)。并且只能由来自另一个进程的主动动作恢复,例如,在调试期间收到任何信号都会使进程进入此状态。

  6. 僵死态

    进程已被终止,但由于某些原因,在进程表中仍然有其task_struct

image-20210319201039276

可执行程序代码是进程的重要组成部分。这些代码从一个可执行文件载入到进程的地址空间执行。一般程序在用户空间执行(用户态)。当一个程序调执行了系统调用或者触发了某个异常,它就陷入了内核空间(内核态)。此时,我们称内核“代表进程执行”并处于进程上下文中。除非在此间隙有更高优先级的进程需要执行并由调度器做出了相应调整,否则在内核退出的时候,程序恢复到用户空间继续执行。

系统调用,异常处理程序和中断处理程序是对内核明确定义的接口。进程只有通过这些接口才能陷入内核执行——对内核的所有访问都必须通过这些接口。

Linux系统中,所有的进程都是PID1init进程的后代。内核在系统启动的最后阶段启动init进程。该进程读取系统的初始化脚本并执行其他的相关程序,最终完成系统启动的整个过程。

进程创建

进程的创建将一个新进程添加到正被管理的进程集时,操作系统需要建立用于管理该进程的数据结构,并在内存中给它分配地址空间,这些行为构成了一个新进程的创建过程, 操作系统会按照如下步骤操作:

  1. 为新进程分配一个唯一的进程描述符。此时,主进程表中会添加一个新表项,每个进程一个表项。
  2. 为进程分配空间。包括进程映像中的所有元素。
  3. 初始化进程控制块(进程描述符)。
  4. 设置正确的链接。即将其放入适当的任务队列中。
  5. 创建或扩充其它数据结构。例如,操作系统可因编制账单和/或评估性能,为每个进程维护一个记账文件。

Linux中的进程创建分为两步,首先,fork()通过拷贝当前进程创建一个子进程。子进程与父进程的区别仅仅在于PIDPPID(子进程将其设置为被拷贝进程的PID)和某些资源和统计量(例如,挂起的信号,它没有必要被继承)。然后,在需要时,通过exec()函数族读取可执行文件并将其载入地址空间开始作为一个全新的进程运行。

fork()使用写时拷贝(copy-on-write)页实现。内核此时并不复制整个进程地址空间,而是让父进程和子进程共享同一个拷贝。只有在需要写入的时候,数据才会被复制,从而使各个进程拥有各自的拷贝。也就是说,资源的复制只有在需要写入的时候才进行,在此之前,只是以只读方式共享。

fork()的实际开销就是复制父进程的页表以及给子进程创建唯一的进程描述符。如果进程创建后马上运行一个可执行的文件,这种优化可以避免拷贝大量根本就不会被使用的数据。

fork()通过clone()系统调用实现。这个调用通过一系列的参数标志来指明父、子进程需要共享的资源。fork()_clone()库函数都根据各自需要的参数标志去调用clone(),然后由clone()去调用do_fork()

do_fork完成了创建中的大部分工作,它的定义在kernel/fork.c文件中。该函数调用copy_process()函数,然后让进程开始运行。copy _process()函数完成如下工作:

  1. 调用dup_task_struct()为新进程创建一个内核栈、thread_info结构和task_struct,这些值与当前进程的值相同。此时,子进程和父进程的描述符是完全相同的。
  2. 检查并确保新创建这个子进程后,当前用户所拥有的进程数目没有超出给它分配的资源的限制。
  3. 子进程着手使自己与父进程区别开来。进程描述符内的许多成员都要被清零或设为初始值。那些不是继承而来的进程描述符成员,主要是统计信息(文件引用计数等)。task_struct中的大多数数据都依然未被修改。
  4. 子进程的状态被设置为TASK_UNINTERRUPTIBLE,以保证它不会投入运行。
  5. copy_process()调用copy_flags()以更新task_structflags成员。表明进程是否拥有超级用户权限的PF_SUPERPRIV标志被清0。表明进程还没有调用exec()函数的PF_FORKNOEXEC标志被设置。
  6. 调用alloc_pid()为新进程分配一个有效的PID
  7. 根据传递给clone()的参数标志,copy_process()拷贝或共享打开的文件、文件系统信息、信号处理函数、进程地址空间和命名空间等。在一般情况下,这些资源会被给定进程的所有线程共享。
  8. 最后,copy_process()做扫尾工作并返回一个指向子进程的指针。

再回到do_fork()函数,如果copy_process()函数成功返回,新创建的子进程被唤醒并让其投入运行。内核有意选择子进程首先执行。因为一般子进程都会马上调用exec()函数族,这样可以避免写时拷贝的额外开销,如果父进程首先执行的话,有可能会开始向地址空间写入。

进程终结
原因 说明
正常完成 进程自行执行一个操作系统服务调用,表示它已经结束运行
超出时限 进程运行时间超过规定的时限
无可用内存 系统无法满足进程需要的内存空间
保护错误 进程试图使用不允许使用的资源或文件,如往只读文件中写或试图访问不允许访问的内存单元
I/O失败 在输入或输出期间发生错误,如找不到文件或某些无效操作
父进程终止 当一个父进程终止时,操作系统可能会自动终止该进程的所有子进程
父进程请求 父进程通常具有终止其任何子进程的权力

进程的终结发生在调用exit()系统调用时,exit()又调用了do_exit()(定义在<kernel/exit.c>文件中):

  1. tast_struct中的标志成员设置为PF_EXITING

  2. 调用del_timer_sync()删除任一内核定时器。根据返回的结果,它确保没有定时器在排队,也没有定时器处理程序在运行。

  3. 然后调用exit_mm()函数释放进程占用的mm_struct,如果没有别的进程使用它们(也就是说,这个地址空间没有被共享),就彻底释放它们。

  4. 接下来调用sem__exit()函数。如果进程排队等候IPC信号,它则离开队列。

  5. 调用exit_files()exit_fs(),以分别递减文件描述符、文件系统数据的引用计数。如果其中某个引用计数的数值降为零,那么就代表没有进程在使用相应的资源,此时可以释放。

  6. 接着把存放在task_structexit_code成员中的任务退出代码置为由exit()提供的退出代码,或者去完成任何其他由内核机制规定的退出动作。退出代码存放在这里供父进程随时检索。

  7. 调用exit_notify()向父进程发送信号,给子进程重新找养父,养父为线程组中的其他线程或者为init进程,并把进程状态(存放在task_struct结构的exit_state中)设成EXIT_ZOMBIE

    如果父进程在子进程之前退出,必须有机制来保证子进程能找到一个新的父亲,否则这些成为孤儿的进程就会在退出时永远处于僵死状态,白白地耗费内存。一旦系统为进程成功地找到和设置了新的父进程,就不会再有出现驻留僵死进程的危险了。init进程会例行调用wait()来检查其子进程,清除所有与其相关的僵死进程。

  8. do_exit()调用schedule()切换到新的进程。因为处于EXIT_ZOMBIE状态的进程不会再被调度,所以这是进程所执行的最后一段代码。do_exit()永不返回。

至此,与进程相关联的所有资源都被释放掉了,还占用的所有内存就是内核栈、thread_info结构和tast_struct结构。此时进程存在的唯一目的就是向它的父进程提供信息。

这意味着,进程终结时所需的清理工作和进程描述符的删除被分开执行。在父进程获得已终结的子进程的信息后,通知内核它并不关注那些信息后,子进程的task_struct结构才被释放。

wait()这一族函数都是通过唯一(但是很复杂)的一个系统调用wait4()来实现的。它的标准动作是挂起调用它的进程,直到其中的一个子进程退出,此时函数会返回该子进程的PID。此外,调用该函数时提供的指针会包含子函数退出时的退出代码。

当最终需要释放进程描述符时,release_task()会被调用,用以完成以下工作:

  1. 它调用__exit_signal(),该函数调用_unhash process(),后者又调用detach_pid()pidhash上删除该进程,同时也要从任务列表中删除该进程。
  2. _exit_signal()释放目前僵死进程所使用的所有剩余资源,并进行最终统计和记录。
  3. 如果这个进程是线程组最后一个进程,并且领头进程已经死掉,那么release_task()就要通知僵死的领头进程的父进程。
  4. release_task()调用put_task_struct()释放进程内核栈和thread_info结构所占的页,并释放tast_struct所占的slab高速缓存。

至此,进程描述符和所有进程独享的资源就全部释放掉了。

进程切换
进程间切换

进程切换可在操作系统从当前正在运行进程中获得控制权的任何时刻(即,当执行进程从用户态转移到内核态之后)发生。

机制 原因 用途
中断 来自当前执行指令的外部 对异步外部事件的反应,如完成一次I/O操作
异常(陷阱) 与当前执行指令有关 处理一个错误或异常事件,如非法的内存或文件访问
系统调用 显示请求 调用系统函数,使用系统调用会将用户进程设为阻塞态

涉及状态变化的完整的进程切换步骤如下:

  1. 保存处理器的上下文,包括程序计数器和其他寄存器。
  2. 更新当前处于运行态进程的进程控制块,包括把进程的状态改变为另一状态(就绪态、阻塞态、退出态)。还须更新其他相关的字段,包括退出运行态的原因和记账信息。
  3. 把该进程的进程控制块移到相应的任务队列(就绪、阻塞)
  4. 选择另一个进程执行,详见进程调度。
  5. 更新所选进程的进程控制块,包括把进程的状态改为运行态。
  6. 更新内存管理数据结构。是否需要更新取决于管理地址转换的方式,详见内存管理。
  7. 载入程序计数器和其他寄存器先前的值,将处理器的上下文恢复为所选进程上次退出运行态时的上下文。

模式切换(指用户态和内核态间切换)的区别在于,模式切换可在不改变当前运行态进程的状态下出现(即不涉及状态变化),此时保存上下文并之后恢复上下文仅需很少的开销。

线程间切换

Linux系统中,当Linux内核执行从一个进程到另一个进程的切换时,会检查当前进程的页目录地址是否与将被调度的进程的相同。若相同,则它们共享同一个地址空间(即同一个进程中的不同线程),所以此时上下文切换仅是从代码的处跳转到代码的另一处。

进程切换分两步:

  1. 切换页目录以使用新的地址空间。
  2. 切换内核栈和硬件上下文。

对于Linux来说,线程和进程的最大区别就在于地址空间。对于线程切换,第1步是不需要做的,第2步是进程和线程切换都要做的。 所以明显是进程切换代价大。

另外一个隐藏的损耗是上下文的切换会扰乱处理器的缓存机制。简单的说,一旦去切换上下文,处理器中所有已经缓存的内存地址一瞬间都作废了。还有一个显著的区别是当你改变虚拟内存空间的时候,处理器的页表缓冲(TLB)会被全部刷新,这将导致内存的访问在一段时间内相当的低效。但是在线程的切换中,不会出现这个问题。

线程和线程在Linux中的实现

进程是资源分配的最小单元,享有资源所有权,线程是调度执行的具体对象,享有被调度执行权。

线程是在进程中活动的可被内核调度的对象。每个线程都拥有一个独立的程序计数器、进程栈和一组进程寄存器。同一个进程中的所有线程可以共享虚拟内存,但拥有独立的虚拟处理器。进程中的所有线程共享该进程的状态和资源,所有线程都驻留在同一块地址空间中,并可访问相同的数据

Linux实现线程的机制非常独特。从内核的角度来说,它并没有线程这个概念。线程仅仅被视为一个与其他进程共享某些资源的进程。每个线程都拥有唯一隶属于自己的task_struct,所以在内核中,它看起来就像是一个普通的进程,只是线程和其他一些进程共享某些资源,如地址空间。

也就是说,组成一个用户级进程的多个用户级线程被映射到共享同一个组ID的多个Linux内核级进程上。因此,这些进程可以共享文件和内存等资源,使得同一个组中的进程调度切换时不需要切换上下文。

线程的创建和普通进程的创建类似,只不过在调用clone()的时候需要传递一些参数标志来指明需要共享的资源:

1
clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, 0);

上面的代码产生的结果和调用fork()差不多,只是父子俩共享地址空间CLONE_VM、文件系统资源CLONE_FS、文件描述符CLONE_FILES和信号处理程序CLONE_SIGHAND。新建的进程和它的父进程就是所谓的线程,但它们都具有各自的用户栈,因为clone()系统调用会为每个进程创建独立的栈空间。

对比一下,一个普通的fork()的实现是:

1
clone(SIGCHLD, 0);

这些clone()用到的参数标志以及它们的作用,这些是在<linux/sched.h>中定义的。

内核线程(进程)

内核经常需要在后台执行一些操作。这种任务可以通过内核线程(独立运行在内核空间的标准进程,内核线程也只能由其他内核线程创建)完成。内核线程和普通的进程间的区别在于内核线程没有独立的地址空间(实际上指向地址空间的mm指针被设置为NULL)。它们只在内核空间运行,从来不切换到用户空间去。内核进程和普通进程一样,可以被调度,也可以被抢占。

守护线程(进程)

Linux Daemon(守护进程)是运行在后台的一种特殊进程。它独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件。它不需要用户输入就能运行而且提供某种服务,不是对整个系统就是对某个用户程序提供服务。Linux系统的大多数服务器就是通过守护进程实现的。常见的守护进程包括系统日志进程syslogdweb服务器httpd等。

守护进程一般在系统启动时开始运行,除非强行终止,否则直到系统关机都保持运行。守护进程经常以超级用户(root)权限运行,因为它们要使用特殊的端口(1-1024)或访问某些特殊的资源。

一个守护进程的父进程是init进程,因为它真正的父进程在fork出子进程后就先于子进程exit退出了,所以它是一个由init继承的孤儿进程。守护进程是非交互式程序,没有控制终端,所以任何输出,无论是向标准输出设备stdout还是标准出错设备stderr的输出都需要特殊处理。

首先我们要了解一些基本概念:

进程组 :

  • 每个进程也属于一个进程组
  • 每个进程主都有一个进程组号,该号等于该进程组组长的PID号 .
  • 一个进程只能为它自己或子进程设置进程组ID

会话:会话(session)是一个或多个进程组的集合。

setsid()函数可以建立一个会话:

如果,调用setsid的进程不是一个进程组的组长,此函数创建一个新的会话期。

  1. 此进程变成该对话期的首进程;
  2. 此进程变成一个新进程组的组长进程;
  3. 此进程没有控制终端,如果在调用setsid前,该进程有控制终端,那么与该终端的联系被解除。 如果该进程是一个进程组的组长,此函数返回错误;
  4. 为了保证这一点,我们先调用fork()然后父进程exit(),此时只有子进程在运行。

现在我们来给出创建守护进程所需步骤:

编写守护进程的一般步骤步骤:

  1. 在父进程中执行fork并让父进程exit退出;
  2. 在子进程中调用setsid函数创建新的会话;
  3. 在子进程中调用chdir函数,让根目录/成为子进程的工作目录;
  4. 在子进程中调用umask函数,设置进程的umask0
  5. 在子进程中关闭任何不需要的文件描述符。

一些说明:

  1. 在后台运行
    为避免挂起控制终端将Daemon放入后台执行。方法是在进程中调用fork使父进程终止,让Daemon在子进程中后台执行。

  2. 脱离控制终端,登录会话和进程组
    有必要先介绍一下Linux中的进程与控制终端,登录会话和进程组之间的关系:进程属于一个进程组,进程组号(GID)就是进程组长的进程号(PID)。登录会话可以包含多个进程组。这些进程组共享一个控制终端。这个控制终端通常是创建进程的登录终端。

    控制终端,登录会话和进程组通常是从父进程继承下来的。我们的目的就是要摆脱它们,使之不受它们的影响。方法是在第1点的基础上,调用setsid()使进程成为会话组长,当进程是会话组长时setsid()调用失败。

    但第一点已经保证进程不是会话组长。setsid()调用成功后,进程成为新的会话组长和新的进程组长,并与原来的登录会话和进程组脱离。由于会话过程对控制终端的独占性,进程同时与控制终端脱离。

  3. 禁止进程重新打开控制终端
    现在,进程已经成为无终端的会话组长。但它可以重新申请打开一个控制终端。可以通过再次fork()使进程不再成为会话组长来禁止进程重新打开控制终端。

  4. 关闭打开的文件描述符
    进程从创建它的父进程那里继承了打开的文件描述符。如不关闭,将会浪费系统资源,造成进程所在的文件系统无法卸下以及引起无法预料的错误。

  5. 改变当前工作目录
    进程活动时,其工作目录所在的文件系统不能卸下。一般需要将工作目录改变到根目录。对于需要转储核心,写运行日志的进程将工作目录改变到特定目录。

  6. 重设文件创建掩码
    进程从创建它的父进程那里继承了文件创建掩模。它可能修改守护进程所创建的文件的存取位。为防止这一点,将文件创建掩模清除:umask(0);

  7. 处理SIGCHLD信号
    处理SIGCHLD信号并不是必须的。但对于某些进程,特别是服务器进程往往在请求到来时生成子进程处理请求。如果父进程不等待子进程结束,子进程将成为僵尸进程从而占用系统资源。如果父进程等待子进程结束,将增加父进程的负担,影响服务器进程的并发性能。在Linux下可以简单地将SIGCHLD信号的操作设为SIG_IGNsignal(SIGCHLD, SIG_IGN);