Linux 内核源代码情景分析(二)(上)

简介: Linux 内核源代码情景分析(二)

第 4 章 进程与进程调度

1、进程四要素

   要给“进程”下一个确切的定义不是件容易的事。不过,一般来说 Linux 系统中的进程都具备下列诸要素:


有一段程序供其执行,就好像一场戏要有个剧本一样。这段程序不一定是进程所专有,可以与其他进程共用,就好像不同剧团的许多场演出可以共用一个剧本一样。

有起码的 “秘行财产” ,这就是进程专用的系统堆栈空间。

有 “户口 ” ,这就是在内核中的一个 task_struct 数据结构,操作系统教科书中常称为“进程控制块”。有了这个数据结构,进程才能成为内核调度的一个基本单位接受内核的调度。同时, 这个结构又是进程的“财产登记卡”,记录着进程所占用的各项资源。

有独立的存储空间,意味着拥行专有的用户空间;进一步,还意味着除前述的系统空间堆栈外还有其专用的用户空间堆栈。注意,系统空间是不能独立的,任何进程都不可能直接 (不通过系统调用) 改变系统空间的内容 (除其本身的系统空间堆栈以外) 。

   这四条都是必要条件,缺了其中任何一条就不称其为“进程”。如果只具备了前面三条而缺第四条, 那就称为 “线程” 。特别地,如果完全没有用户空间,就称为“内核线程” (kernel thread) ;而如果共享用户空间则就称为 “用户线程” 。在不致引起混淆的场合,二者也都往往简称为“线程”。读者在第 2 章中看到过的 kswapd ,就是一个内核线程。读者要注意,不要把这里的“线程”与有些系统中在用户空间的同一进程内实现的 “线程” 相混淆。那种线程显然不拥有独立、专用的系统堆栈,也不作为一个调度单位直接受内核调度。而且,既然 Linux 内核提供了对线程的支持,一般也就没有必要再在进程内部,即用户空间中自行实现线程。


   另一方面,进程与线程的区分也不是十分严格的,一般在讲到进程时常常也包括了线程。事实上, 在 Linux (以及Unix) 系统中,许多进程在“诞生”之初都与其父进程共用同一个存储空间,所以严格说来还是线程;但是子进程可以建立其自己的存储空间,并与父迸程分道扬镳,成为真止意义上的进程。再说,线程也有 “pid",也有 task_struct 结构,所以这两个词在使用中有时候并不严格加以区分, 要根据上下文理解其含意。


   还有,在 Linux 系统中“进程” (process) 和“任务” (task) 是同一个意思,在内核的代码中也常常混用这两个名词和概念。例如,每一个进程都要有一个 task_struct 数据结构,而其号码却又是 pid; 唤醒一个睡眠进程的函数名为 wake_up_process() 。之所以有这样的情况是因为 Linux 源自 Unix 和 i386 系统结构,而 Unix 中的进程在 Intel 的技术资料中则称为“任务”(严格说来有点差别,但是对 Linux 和 Unix 的实现来说是一码事)。


   Linux 系统运行时的第一个进程是在初始化阶段“捏造”出来的。而此后的进程或线程则都是由一个业已存在的进程像细胞分裂那样通过系统调用复制出来的,称为 “fork"(分叉)或“clone”(克隆)。


   除上述最起码的“财产”,即 task_struct 数据结构和系统堆栈之外,一个进程还要有些附加的资源。 例如,上面说过,“独立”的存储空问意味着进程拥有用户空间,因此就要有用于虚存管理的 mm_struct 数据结构以及下属的 vm_area 数据结构,以及相应的页面目录项和页面表。但那些都是第二位的,从属于 task_struct 的资源,而 task_struct 数据结构则在这方面起着登记卡的作用。至于进程的具体实现, 则在相当程度上取决于宿主 CPU 的系统结构。


   在转入详细介绍进程的各个要素之前,我们先讲一下 i386 系统结构所提供的进程管理机制以及 Linux 内核对这种机制的特殊运用和处理。读者可以结合第2章中的有关内容阅读。


   Intel 在 i386 系统结构的设计中考虑到了进程(任务)的管理和调度,并从硬件上支持任务间的切换。为此目的,Intel 在 i386 系统结构中增设了另一种新的段,叫做“任务状态段” TSS。 一个 TSS 虽说像代码段、数据段等一样,也是一个"段”,实际上却只是一个 104 字节的数据结构、或曰控制块, 用以记录一个任务的关键性的状态信息,包括:


任务切换前夕(也就是切入点上)该任务各通用寄存器的内容。

任务切换前夕(切入点上)该任务各个段寄存器(包括ES、CS、SS、DS、FS和ES)的内容。

任务切换前夕(切入点上)该任务EFLAGS寄存器的内容。

任务切换前夕(切入点上)该任务指令地址寄存器EIP的内容。

指向前一个任务的 TSS 结构的段选择码。当前任务执行 IRET 指令时,就返回到由这个段选择码所指的(TSS所代表的)任务(返回地址则由堆栈决定)。

该任务的 LDT 段选择码,它指向任务的 LDT。

控制寄存器 CR3 的内容,它指向任务的页面目录。

二个堆栈指针,分别为当任务运行于0级、1级和2级时的堆栈指针,包括堆栈段寄存器SS0、 SS1和SS2,以及ESP0、ESP1和ESP2的内容。注意,在CPU中只有一个SS和一个ESP 寄存器,但是 CPU 在进入新的运行级别时会自动从当前任务的TSS中装入相应SS和ESP 的内容,实现堆栈的切换。

一个用于程序跟踪的标志位 T 。当T标志位为 1 时,CPU就会在切入该进程时产生一次 debug 异常,这样就可以在 debug 异常的服务程序中安排所需的操作,如加以记录、显示、等等。

在一个 TSS 段中,除了基本的104字节的TSS结构以外,还可以有一些附加的信息。其中之一是表示I/O权限的位图。i386系统结构允许 I/O 指令在比0级为低的状态下执行,也就是说可以将外设驱动实现于一个既非内核(0级)也非用户(3级)的空间中,这个位图就是用于这个目的。另一个是“中断重定向位图”,用于 vm86 模式。

   前面讲过,每个进程都有一个 task_struct 数据结构和一片用作系统空间堆栈的存储空间。这二者缺一不可,又有紧密的联系,所以在物理存储空间中也连在一起。内核在为每个进程分配一个 task_struct 结构时,实际上分配两个连续的物理页面 (共8192字节) 。这两个页面的底部用作进程的 task_struct 结构,而在结构的上面就用作进程的系统空间堆栈。下图为进程系统堆栈示意图:

(1)运行状态

// include/linux/sched.h
#define TASK_RUNNING    0
#define TASK_INTERRUPTIBLE  1
#define TASK_UNINTERRUPTIBLE  2
#define TASK_ZOMBIE   4
#define TASK_STOPPED    8

   状态 TASK_INTERRUPTIBLE 和 TASK_UNINTERRUPTIBLE 均表示进程处于速眠状态。但是, TASK_UNINTERRUPTIBLE 表示进程处于 “深度睡眠” 而不受 “信号” ( signal,也称 “软中断” ) 的打扰,而 TASK_INTERRUPTIBLE 则可以因 “信号” 的到来而被唤醒。内核中提供了不同的函数,让一个进程进入不同深度的睡眠或将进程从睡眠中唤醒。具体地说,函数 sleep_on() 和 wake_up() 用于深度睡眠,而 interruptible_sleep_on() 和wake_up_interruptible() 则用于浅度睡眠。深度睡眠一般只用于临界区和关键性的部位,而 “可中断” 的睡眠那就是通用的了。特别,当进程在 “阻塞性”(blocking) 的系统调用中等将某一事件发生时,应该进入“可中断” 睡眠间不应深度睡眠。例如,当进程等待操作人员按某个键的时候,就不应该进入深度睡眠,否则就不能对别的事件作出反应,别的进程就不能通过发一个信号来 “杀” 掉这个进程了。还应该注意,这里的 INTERRUPTIBLE 或 UNINTERRUPTIBLE 跟 “中断” 毫无关系,而只是说睡眠能否因其他事件而中断,即唤醒。不过,所谓其他事件主要是 “信号”,而信号的概念实际上与中断的概念是相同的,所以这里所谓 INTERRUPTIBLE 也是指这种 “软中断” 而言。


   TASK_RUNNING 状态并不是表示一个进程止在执行中,或者说这个进程就是 “当前进程”,而是表示这个进程可以被调度执行而成为当前进程。当进程处于这样的可执行(或就绪)状态时,内核就将该进程的 task_struct 结构通过其队列头 run_list (见309行)挂入一个 “运行队列”。


   TASK_ZOMBIE 状态表示进程已经 “去世”(exit)而 “户口” 尚未注销。

   TASK_STOPPED 主要用于调试目的。进程接收到一个 SIGSTOP 信号后就将运行状态改成 TASK_STOPPED 而进入“挂起”状态,然后在接收到一个 SIGCONT 信号时又恢复继续运行。

2、进程三部曲:创建、执行与消亡

   就像世上万物都有产生、发展与消亡的过程一样,每个进程也有被创建、执行某段程序以及最后消亡的过程。在 Linux 系统中,第一个进程是系统固有的、与生俱来的或者说是由内核的设计者安排好了的。内核在引导并完成了基本的初始化以后,就有了系统的第一进程(实际上是内核线程)。除此之外,所有其他的进程和内核线程都这个原始进程或其子孙进程所创建,都是这个原始进程的 “后代” 。在 Linux 系统中,一个新的进程一定要由一个已经存在的进程“复制”出来,而不是“创造”出来(而所谓“创建”实际就是复制)。所以,Linux 系统(Unix也一样)并不向用户(即进程)提供类似这样的系统调用:

int creat_proc(int (*fn)(void*), void *arg, unsigned long options);

   可是在很多操作系统(包括一些Unix的变种)中都采用了 “一揽子” 的方法。它“创造”出一个进程,并使该进程从函数指针 fn 所指的地方开始执行。根据不同的情况和设计,参数 fn 也可以换成一个可执行程序的文件名。这里所谓“创造”,包括为进程分配所需的资源、包括属于最低限度的 task_struct 数据结构和系统空间堆栈,并初始化这些资源;还要设置其系统空间堆栈,使得这个新进程看起来就好像是一个本来就已经存在而正在唾眠的进程。当这个进程被调度运行的时候,其“返回地址”,也就是“恢复”运行时的下一条指令,则就在 fn 所指的地方。这个“子进程” 生下来时两手空空,却可以完全独立,并不与其父进程共享资源。


   但是,Linux (以及Unix)采用的方法却不同。


   Linux 将进程的创建与目标程序的执行分成两步。第一步是从已经存在的“父进程”中像细胞分裂一样地复制出一个 “子进程” 。这里所谓像“细胞分裂一样”,只是打个比方,实际上,复制出来的子进程有自己的 task_struct 结构和系统空间堆栈,但与父进程共享其他所有的资源。例如,要是父进程打开了五个文件,那么子进程也有五个打开的文件,而且这些文件的当前读写指针也停在相同的地方。 所以,这一步所做的是“复制”。Linux 为此提供了两个系统调用,一个是 fork(),另一个是 clone() 。 两者的区别在于 fork() 是全部复制,父进程所有的资源全都通过数据结构的复制“遗传”给步进程。 而 clone() 则可以将资源有选择地复制给厂进程,而没有复制的数据结构则通过指针的复制让子进程共享。在极端的情况下,一个进程可以 clone() 出一个线程。所以,系统调用 fork() 是无参数的,而 clone() 则带有参数。读者也许已经意识到,fork() 其实比clone() 更接近本来意义上的“克隆”。确实是这样, 原因在于 fork() 从 Unix 的初期即已存在,那时候“克隆”这个词还不像现在这么流行,而既然业已存在,就不宜更改了。否则,也许应该互换一下名字。后来,又增设了一个系统调用 vfork() ,也不带参数,但是除 task_struct 结构和系统空间堆栈以外的资源全都通过数据结构指针的复制“遗传”,所以 vfork() 出来的是线程而不是进程。读者将会看到,vfork() 主要是出于效率的考虑而设计并提供的。


   第二步是目标程序的执行。一般来说,创建一个新的进程是因为有不同的目标程序要让新的程序去执行 (但也不一定),所以,复制完成以后,子进程通常要与父进程分道扬镳,走自己的路。Linux 为此提供了一个系统调用 execve(),让一个进程执行以文件形式存在的一个可执行程序的映象。


   读者也许要问:这两种方案到底哪一种好?应该说是各有利弊。但是更应该说,Linux从Unix继承下来的这种分两步走,并且在第一步中采取复制方式的方案,利远大于弊。从效率的角度看,分两步走很行好处。所谓复制,只是进程的基本资源的复制,如 task_struct 数据结构、系统空间堆栈、页面表等等,对父进程的代码及全局变量则并不需要复制,而只是通过只读访问的形式实现共享,仅在需要写的时候才通过 copy_on_write 的手段为所涉及的页面建立个新的副本。所以,总的来说复制的代价是很低的,但是通过复制而继承下来的资源则往往对子进程很有用。读者以后会看到,在计算机网络的实现中,以及在 client/server 系统中的 server 一方的实现中,fork() 或 clone() 常常是最自然、最有效、最适宜的手段。笔者有时候简直怀疑,到底足先有 fork() 还是先有 client/server,因为 fork() 似乎就是专门为此而设计的。更重要的好处是,这样有利于父、子进程间通过 pipe 来建立起一种简单有效的进程间通信管道,并且从而产生了操作系统的用户界面即 shell 的 “管道” 机制。这一点,对于 Unix 的发展和推广应用,对于Unix程序设计环境的形成,对于Unix程序设计风格的形成,都有着非常深远的影响。可以说,这是一项天才的发明,它在很大程度上改变了操作系统的发展方向。


   当然,从另一角度,也就是从程序设计界面的角度来看,则“一揽子”的方案更为简洁。不过 fork() 加 execve() 的方案也并不复杂很多。进一步说,这也像练武或演戏一样有个固定的“招式”,一一掌握了以后就不觉得复杂,也很少变化了。再说,如果有必要也可以通过程序库提供一个 “一揽子” 的库函数,将这两步包装在一起。


   创建了子进程以后,父进程有二个选择。第一是继续走自己的路,与父进程分道扬镳。只是如果子进程先于父进程“去世”,则由内核给父进程发一个报丧的信号。第二是停下来,也就是进入睡眠状态,等待了一进程完成其使命而最终去世,然后父进程再继续运行。Linux为此提供了两个系统调用, wait4() 和 wait3()。两个系统调用基本相同,wait4() 等行某个特定的子进程去世,而 wait3() 则等待任何一个子进程去世。第三个选择是“自行退出历史舞台”,结束自己的生命。Linux 为此设置了一个系统调用 exit() 。这里的第三个选择其实不过是第一个选择的一种特例,所以从本质上说是两种选择:一种是父进程不受阻的 (non_blocking) 方式,也称为“异步”的方式;另一种是父进程受阻的 (blocking) 方式,或者也称为 “同步” 的方式。

3、系统调用fork()、vfork()、与clone()

    前面已经简要地介绍过 fork() 与 clone() 二者的作用和区别。这里先来看一下二者在程序设计接口上的不同:

       #include <unistd.h>
       pid_t fork(void);

       #include <sched.h>
       int clone(int (*fn)(void *), void *child_stack,
                 int flags, void *arg, ...
                 /* pid_t *ptid, struct user_desc *tls, pid_t *ctid */ );
 

   系统调用 __clone() 的主要用途是创建一个线程,这个线程可以是内核线程,也可以是用户线程。 创建用户空间线程时,可以给定子线程用户空间堆栈的位置,还可以指定子进程运行的起点。同时, 也可以用 __clone() 创建进程,有选择地复制父进程的资源。而 fork() ,则是全面地复制。还有一个系统调用 vfork() ,其作用也是创建一个线程,但主要只是作为创建进程的中间步骤,目的在于提高创建时的效率,减少系统开销,其程序设计接口则与fork相同。


   这几个系统调用的代码都在 arch/i386/kernel/process.c 中:

// arch/i386/kernel/process.c
asmlinkage int sys_fork(struct pt_regs regs)
{
  return do_fork(SIGCHLD, regs.esp, &regs, 0);
}

asmlinkage int sys_clone(struct pt_regs regs)
{
  unsigned long clone_flags;
  unsigned long newsp;

  clone_flags = regs.ebx;
  newsp = regs.ecx;
  if (!newsp)
    newsp = regs.esp;
  return do_fork(clone_flags, newsp, &regs, 0);
}

/*
 * This is trivial, and on the face of it looks like it
 * could equally well be done in user mode.
 *
 * Not so, for quite unobvious reasons - register pressure.
 * In user mode vfork() cannot have a stack frame, and if
 * done by calling the "clone()" system call directly, you
 * do not have enough call-clobbered registers to hold all
 * the information you need.
 */
asmlinkage int sys_vfork(struct pt_regs regs)
{
  return do_fork(CLONE_VFORK | CLONE_VM | SIGCHLD, regs.esp, &regs, 0);
}

   可见,二个系统调用的实现都是通过 do_fork() 来完成的,不同的只是对 do_fork() 的调用参数。关于这些参数所起的作用,读了 do_fork() 的代码以后就会清楚。注意 sys_clone() 中的regs.ecx,就是调用 __clone() 时的参数 child_stack,读者如果还不清楚,可以回到第3章“系统调用” 节顺着代码再走一遍。调用 __clone() 时可以为子进程设置一个独立的用户空间堆栈 (在同一个用户空间中),如果 child_stack 为 0,就表示使用父进程的用户空间堆栈。这三个系统调用的主体部分 do_fork() 是在 kernel/fork.c 中定义的。这个函数比较大,计我们逐段往下看:

(1)do_fork()

// kernel/fork.c
/*
 *  Ok, this is the main fork-routine. It copies the system process
 * information (task[nr]) and sets up the necessary registers. It also
 * copies the data segment in its entirety.  The "stack_start" and
 * "stack_top" arguments are simply passed along to the platform
 * specific copy_thread() routine.  Most platforms ignore stack_top.
 * For an example that's using stack_top, see
 * arch/ia64/kernel/process.c.
 */
int do_fork(unsigned long clone_flags, unsigned long stack_start,
      struct pt_regs *regs, unsigned long stack_size)
{
  int retval = -ENOMEM;
  struct task_struct *p;
  // 第560行的宏操作DECLARE_MUTEX_LOCKED()定义和创建了一个用于进程间互斥和同步的信
  // 号量,其定义和实现见第6章“进程间通信”。
  DECLARE_MUTEX_LOCKED(sem);

  // 参数 clone_flags 由两部分组成,其最低的字行为信号类型,用以规定子进程去世时应该向父进程
  // 发出的信号。我们也经看到,对于fork()和vfork()这个信号就是SIGCHLD,而对__clone()则该位段
  // 可由调用者决定。第二部分是一些表示资源和特性的标志位,这些标志位是在
  // include/linux/sched.h 中定义的:
  // 对于fork(),这一部分为全0,表现对有关的资源都要复制而不是通过指针共享。向对vfbrk(),
  // 则为 CLONE_VFORK | CLONE_VM ,表示父、子进程共用(用户)虚存区间,并且当子进程释放其虚
  // 存区间时要唤醒父进程。至于__clone(),则这一部分完全由调用者设定而作为参数传递下来。其中标
  // 志位CLONE_PID有特殊的作用,当这个标志位为1时,父、子进程(线程)共用同一个进程号,也
  // 就是说,子进程虽然有其自己的task_struct数据结构,却使用父进程的pid。但是,只有0号进程,也
  // 就是系统中的原始进程(实际上是线程),才允许这样来调用__clone(),所以564行对此加以检查。
  if (clone_flags & CLONE_PID) {
    /* This is only allowed from the boot up thread */
    if (current->pid)
      return -EPERM;
  }
  
  current->vfork_sem = &sem;
// 接着,通过alloc_task_struct()为子进程分配两个连续的物理页而,低端用作子进程的task_struct 
// 结构,高端则用作其系统空间堆栈.
  p = alloc_task_struct();
  if (!p)
    goto fork_out;
    
// 注意574行的赋值为整个数据结构的赋值。这样,父进程的整个 task_struct 就被复制到了子进程
// 的数据结构中。经编译以后,这样的赋值是用memcpyS实现的,所以效率很高。
  *p = *current;

  retval = -EAGAIN;
// 在task_struct结构中有个指针user,用来指向一个user_struct结构。一个用户常常有许多个进程,
// 所以有关用户的一些信息并不专属于某一个进程。这样,属于同一用户的进程就可以通过指针user共
// 享这些信息。显然,每个用户有且只有一个user_struct结构。结构中有个计数器count,对属于该用户
// 的进程数量计数。可想而知,内核线程并不属于某个用户,所以其task_struct中的user指针为0。这个
// 数据结构的定义在include/lmux/sched.h中:
//
// 各进程的 task_struct 结构中还有个数组 rlim,对该进程占用各种资源的数量作出限制,而 
// rlim[RLIMIT_NPROC] 就规定了该进程所属的用户可以拥有的进程数量。所以,如果当前进程是
// 一个用户进程,并且该用户拥有的进程数量己经达到了规定的限制值,就再不允许它 fork()
// 了。
  if (atomic_read(&p->user->processes) >= p->rlim[RLIMIT_NPROC].rlim_cur)
    goto bad_fork_free;
  atomic_inc(&p->user->__count);
  atomic_inc(&p->user->processes);
  /*
   * Counter increases are protected by
   * the kernel lock so nr_threads can't
   * increase under us (but it may decrease).
   */
// 那么,对于不属于任何用户的内核线程怎么办呢? 587行中的两个计数器就是为进程的总量而设的。
  if (nr_threads >= max_threads)
    goto bad_fork_cleanup_count;

// 一个进程除了属于某一个用户之外,还属于某个“执行域”。总的来说,Linux是Unix的一个变种,
// 并且符合POSIX的规定。但是,有很多版本的操作系统同样是Unix变种,同样符合POSIX规定,互
// 相之间在实现细节上却仍然有明显的不同。例如,AT&T的Sys V和BSD4.2就有相当的不同,而Sun
// 的Solaris又有区别,这就形成了不同的执行域。如果一个进程所执行的程序是为Solaris开发的,那么
// 这个进程就属于Solaris执行域PER_SOLARIS。当然,在Linux上运行的绝大多数程序都属于Linux
// 执行域。在task_struct结构中有一个指针exec_domain,可以指向一个exec_domain数据结构。
// 那是在 include/linux/personality.h 中定义的
  get_exec_domain(p->exec_domain);

// 同样的道理,每个进程所执行的程序属于某种可执行映象格式,如a.out格式、elf格式、甚至java
// 虚拟机格式。对这些不同格式的支持通常是通过动态安装的驱动模块来实现的。所以task_struct结构
// 中还有一个指向 linux_binfmt 数据结构的指针binfmt , 而 do_fork()中593行的 
// __MOD_INC_USE_COUNT() 就是对有关模块的使用计数器进行操作。
  if (p->binfmt && p->binfmt->module)
    __MOD_INC_USE_COUNT(p->binfmt->module);

  p->did_exec = 0;
  p->swappable = 0;
// 为什么要在597行把状态设成TASK_UNINTERRUPTIBLE呢?这是因为在 get_pid()中产生一个
// 新pid的操作必须是独占的,当前进程可能会因为一时进不了临界区而只好暂时进入睡眠状态等待,
// 所以才事先把状态设成 UNINTERRUPTIBLE。
  p->state = TASK_UNINTERRUPTIBLE;
// 函数 copy_flags() 将参数clone_flags中的标志位略加补充和变换,然后写入p->flags,
// 这个函数的代码也在fork.c中。读者可以自己阅读。
  copy_flags(clone_flags, p);
// 至于600行的 get_pid(),则根据clone_flags中标志位CLONE_PID的值,或返回父进程(当前进
// 程)的pid,或返回一个新的pid放在了进程的task_struct中。函数get_pid()的代码也在fork.c 中
  p->pid = get_pid(clone_flags);

  p->run_list.next = NULL;
  p->run_list.prev = NULL;

  if ((clone_flags & CLONE_VFORK) || !(clone_flags & CLONE_PARENT)) {
    p->p_opptr = current;
    if (!(p->ptrace & PT_PTRACED))
      p->p_pptr = current;
  }
  p->p_cptr = NULL;
// 我们在前一节中提到过 wait4()和wait3(), 一个进程可以停下来等待其子进程完成使命。为此,在 
// task_struct 中设置了一个队列头部 wait_chldexit,前面在复制task_struct结构时把这也照抄了
// 过来,而子进程此时尚未“出生”,当然谈不上子进程的等待队列,所以要在611行中加以初始化。
  init_waitqueue_head(&p->wait_chldexit);
  p->vfork_sem = NULL;
  spin_lock_init(&p->alloc_lock);

// 类似地,对各种信息量也要加以初始化。这里615和616行是对子进程的待处理信号队列以及有
// 关结构成分的初始化。对这些与信号有关的结构成分我们将在“进程间通信”的信号一节中详细介绍。
// 接下来是对 task_struct 结构中各种计时变量的初始化,我们将在“进程调度” 一节中介绍这些变量。
// 在这里我们不关心对多处理器SMP结构的特殊考虑,所以也跳过627一637行。
  p->sigpending = 0;
  init_sigpending(&p->pending);

  p->it_real_value = p->it_virt_value = p->it_prof_value = 0;
  p->it_real_incr = p->it_virt_incr = p->it_prof_incr = 0;
  init_timer(&p->real_timer);
  p->real_timer.data = (unsigned long) p;

  p->leader = 0;    /* session leadership doesn't inherit */
  p->tty_old_pgrp = 0;
  p->times.tms_utime = p->times.tms_stime = 0;
  p->times.tms_cutime = p->times.tms_cstime = 0;
#ifdef CONFIG_SMP
  {
    int i;
    p->has_cpu = 0;
    p->processor = current->processor;
    /* ?? should we just memset this ?? */
    for(i = 0; i < smp_num_cpus; i++)
      p->per_cpu_utime[i] = p->per_cpu_stime[i] = 0;
    spin_lock_init(&p->sigmask_lock);
  }
#endif
  p->lock_depth = -1;   /* -1 = no lock */
// 最后,task_struct 结构中的 start_time 表示进程创建的时间,向全局变量jiffies的数值就是以时钟
// 中断周期为单位的从系统初始化开始至此时的时间。
// 至此,对 task_struct 数据结构的复制与初始化就基本完成了。下面就轮到其他的资源了:
  p->start_time = jiffies;

  retval = -ENOMEM;
  /* copy all the process information */
// 函数copy_files()有条件地复制已打开文件的控制结构,这种复制只有在clone_flags中
// CLONE_FILES 标志位为 0 时才真正进行,否则就只是共享父进程的已打开文件。当一个进程有己打开
// 文件时,task_struct 结构中的指针files指向一个files_struct数据结构,否则为0。
// 所有与终端设备tty相联系的用户进程的头三个文件,即stdin、stdout、及stderr,都是预先打开的,
// 所以指针一般不会是 0。数据结构 files_struct 是在include/linux/sched.h中定义的
//(详见“文件系统”一章),copy_files() 的代码则还是在fork.c中:
  if (copy_files(clone_flags, p))
    goto bad_fork_cleanup;
// 除 files_struct 数据结构外,还有个fs_struct数据结构也是与文件系统有关的,也要通过共享或复
// 制遗传给子进程。类似地,copy_fs()也是只有在clone_flags中CLONE_FS标志位为 0 时才加以复制。
// task_struct 结构中的指针指向一个fs_struct数据结构,结构中记录的是进程的根目录root、当前工作目
// 录pwd、一个用于文件操作权限管理的umask,还有一个计数器,其定义在include/linux/fs_struct.h中
// (详见“文件系统”一章)。函数copy_fs()连同几个有关低层函数的代码也在kernel/fork.c中。我们把这些代
// 码留给读者:
  if (copy_fs(clone_flags, p))
    goto bad_fork_cleanup_files;
// 接着是关于对信号的处理方式。是否复制父进程对信号的处理是由标志位CLONE_SIGHAND控
// 制的。信号基本上是一种进程间通信手段,信号之于一个进程就好像中断之于一个处理器。进程可以
// 为各种信号设置用于该信号的处理程序,就好像系统可以为各个中断源设置相应的中断服务程序一样.
// 如果一个进程设置了信号处理程序,其task_struct结构中的指针sig就指向一个signal_struct数据结构。
// 这种结构是在include/linux/sched.h中定义的:
  if (copy_sighand(clone_flags, p))
    goto bad_fork_cleanup_fs;
// 然后是用户空间的继承。进程的task_struct结构中有个指针mm,读者已经相当熟悉了,它指向一
// 个代表着进程的用户空间的mm_struct数据结构。由于内核线程并不拥有用户空间,所以在内核线程
// 的task_struct结构中该指针为0。有关mm_struct及其下属的vm_area_struct等数据结构已经在第2章
// 中介绍过,这里不再重复。
  if (copy_mm(clone_flags, p))
    goto bad_fork_cleanup_sighand;

// 当 CPU 从 copy_mm() 回到 do_fork() 中时,所有需要有条件复制的资源都已经处理完了。
// 读者不妨回顾一下,当系统调用fork()通过sys_fork()进入do_fork()时,其clone_flags为SIGCHLD,
// 也就是说,所有的标志位均为0,所以copy_files() 、copy_fs() 、copy_sighand()以及copy_mm()
// 全部真正执行了,这四项资源全都复制了。而当vfork()经过sys_vfork进入do_fork()时,
// 则其clone_flags为 VFORK|CLONE_VM|SIGHLD,所以只执行了 copy_files() 、copy_fs()以及 
// copy_sighand();而 copy_mm(),则因标志位 CLONE_VM 为1,只是通过指针共享其父进程的mm_struct,
// 并没有一份自己的副本。这也就是说,经vfork()复制的是个线程,只能靠共享其父进程的存储空间度日,
// 包括用户空间堆栈在内。至于 __clone(),则取决于调用时的参数。当然,最终还得取决于父进程具有
// 什么资源, 要是父进程没有已打开文件,那么即使执行了 copy_files(),也还是空的。
// 
// 回到do_fork()的代码111。前面已通过alloc_task_struct()分配了两个连续的页间,其低端用作
// task_struct结构,已经基本上复制好了;而用作系统空间堆栈的高端,却还没有复制。现在就由
// copy_thread()来做这件事了。这个函数的代码在 arch/i386/kernel/process.c 中:
  retval = copy_thread(0, clone_flags, stack_start, stack_size, p, regs);
  if (retval)
    goto bad_fork_cleanup_sighand;
  p->semundo = NULL;
  
  /* Our parent execution domain becomes current domain
     These must match for thread signalling to apply */
// 代码中的 parent_exec_id 表示父进程的执行域,self_exec_id 为本进程的执行域,swappable
// 表示本进程的存储页面可以被换出,exit_signal 为本进程执行exit()时应向父进程发出的信号,
// pdeath_signal 为要求父进程在执行exit()时向本进程发出的信号。   
  p->parent_exec_id = p->self_exec_id;

  /* ok, now we should be set up.. */
  p->swappable = 1;
  p->exit_signal = clone_flags & CSIGNAL;
  p->pdeath_signal = 0;

  /*
   * "share" dynamic priority between parent and child, thus the
   * total amount of dynamic priorities in the system doesnt change,
   * more scheduling fairness. This is only important in the first
   * timeslice, on the long run the scheduling behaviour is unchanged.
   */
// 此外,task_struct 结构中 counter 字段的值就是进程的运行时间配额,这里将父进程的时间配额分成
// 两半,让父、子进程各有原值的一半。
  p->counter = (current->counter + 1) >> 1;
  current->counter >>= 1;
  if (!current->counter)
    current->need_resched = 1;

  /*
   * Ok, add it to the run-queues and make it
   * visible to the rest of the system.
   *
   * Let it rip!
   */
  retval = p->pid;
  p->tgid = retval;
  INIT_LIST_HEAD(&p->thread_group);
  write_lock_irq(&tasklist_lock);
// 如果创建的是线程,则还要通过task_struct结构中的队列头thread_group与父进程链接起来,
// 形成一个 “线程组”。
  if (clone_flags & CLONE_THREAD) {
    p->tgid = current->tgid;
    list_add(&p->thread_group, &current->thread_group);
  }
// 接着,就要让子进程进入它的关系网了。先通过SET_LTNKS(p)将子进程的task_struct结构链入内核的
// 进程队列,然后又通过hash_pid()将其链入按其pid计算得的杂凑队列。有关这些队列的详情可参看
// “进程”以及“进程的调度与切换”两节中的有关叙述。  
  SET_LINKS(p);
  hash_pid(p);
  nr_threads++;
  write_unlock_irq(&tasklist_lock);

  if (p->ptrace & PT_PTRACED)
    send_sig(SIGSTOP, p, 1);
// 最后,通过 wake_up_process() 将子进程“唤醒”, 也就是将其挂入可执行进程队列等待调度。
// 有关详情可参看“过程的睡眠与唤醒” 一节。
  wake_up_process(p);   /* do this last */
  ++total_forks;
// 至此,新进程的创建已经完成了,并且已经挂入了可运行进程的队列接受调度。子进程与父进程
// 在用户空间中具有相同的返回地址,然后才会因用户空间中程序的安排而分开。同时,由于当父进程
// (当前进程)从系统调用返回的前夕可能会接受调度,所以,到底谁会先返回到用户空间是不确定的。
// 不过,一般而言,由于父、子进程适用相同的调度政策,而父进程在可执行进程队列中排在子进程前
// 面,所以父进程先运行的可能较大。

fork_out:
// 还有一种特殊情况要考虑。当调用do_fork()的参数中CLONE_VFORK标志位为1时,一定要保
// 证让子进程先运行,一直到了进程通过系统调用execve()执行一个新的可执行程序或者通过
// 系统调用 exit() 退出系统时,才可以恢复父进程的运行。为什么呢?这要从用户空间的复制
// 或共享这个问题说起。 前面读者已经看到,在创建子进程时,对于父进程的用户空间可以通过
// 复制父进程的 mm_struct 及其下属的各个vm_area_struct数据结构,再加上父进程的页面目录和页面表
// 来继承;也可以简单地复制父进程的task_struct结构中指向其mm_struct结构的指针来共享,具体取决于
// CLONE_VM标志位的值。 当CLONE_VM标志位为1,因而父、子进程通过指针共享用户空间时,
// 父、子进程是在真正的意义上共享用户空间,父进程写入其用户空间的内容同时也“写入”了进程的用户空间,
// 反之亦然。如果说, 在这种情况下父、子进程各自对其数据区的写入可能会引起问题的话,
// 那么对堆栈区的写入可就是致命的了。而每次对子程序的调用都是对堆栈区的写入!由此可见,
// 在这样的情况下绝不能让两个进程都回到用户空间并发地运行;否则,必然是两个进程最终都乱来
// 一气或者因非法越界访问而死亡。解决的办法只能是“扣留”其中一个进程,而只让一个进程回到用户空间,
// 直到两个进程不再共享它们的用户空间或其中一个进程(必然是回到用户空间运行的那个进程)消亡为止。
// 所以,do_fork()中的703行利704行在CLONE_VFORK标志为 1 并且 fork 子进程成功的情况下,
// 通过让当前进程(父进程)在一个信号量上执行一次down()操作,以达到扣留父进程的目的。我们来
// 看看具体是怎样实现的。
// 首先,信号量sem是在函数开头时的560行定义的一个局部量(名曰DECLARE,实际上为之分配了空间):
  if ((clone_flags & CLONE_VFORK) && (retval > 0)) 
// DECLARE_MUTEX_LOCKED 定义这个信号量中资源的数量为 0 。当资源数量为1时,第一个执行down()操作
// 的进程进入临界区,而使资源数量变成了 0,以后执行 down() 操作的进程便会因为资源为0而被拒之
// 门外进入睡眠,直到第一个进程归还资源离开临界区时才被唤醒。而现在这个信号量的资源从一开始
// 就是0,所以第一个对此执行down()操作的进程就会进入睡眠,一直要到某个进程往这个信号量中投
// 入资源,也就是执行一次up()操作时才会被唤醒。
//
// 那么,谁来投入资源呢?在“系统调用execve()”一节中读者将看到,子进程在通过execve()执
// 行一个新的可执行程序时会做这件事。此外,子进程在通过exit()退出系统时也会做这件事。这里还要
// 指出,这个信号量是do_fork()的一个局部变量,所以在父进程的系统空间堆栈中,而子进程在其
// task_struct结构中有指向这个信号量的指针(即 vfork_sem,见do_fork()的第554行和560行)。既然
// 父进程一直要睡眠到子进程使用这个信号量以后,信号量所在的空间就不会受到打扰。还应指出,
// CLONE_VM要与CLONE_VFORK结合使用,否则就会发生前述的问题,除非在用户程序中采取了特
// 殊的预防措施。
// 不管怎样,子进程的创建终于完成了,让我们祝福这新的生命!可是,如果子进程只具有与父进
// 程相同的可执行程序和数据,只是父进程的“影子”,那又有什么意义呢?子进程必须走自己的路,这
// 就是下一节“系统调用execve()”所要讲述的内容了。
    down(&sem);
  return retval;

bad_fork_cleanup_sighand:
  exit_sighand(p);
bad_fork_cleanup_fs:
  exit_fs(p); /* blocking */
bad_fork_cleanup_files:
  exit_files(p); /* blocking */
bad_fork_cleanup:
  put_exec_domain(p->exec_domain);
  if (p->binfmt && p->binfmt->module)
    __MOD_DEC_USE_COUNT(p->binfmt->module);
bad_fork_cleanup_count:
  atomic_dec(&p->user->processes);
  free_uid(p->user);
bad_fork_free:
  free_task_struct(p);
  goto fork_out;
}

(2)clone_flags

// include/linux/sched.h
/*
 * cloning flags:
 */
#define CSIGNAL   0x000000ff  /* signal mask to be sent at exit */
#define CLONE_VM  0x00000100  /* set if VM shared between processes */
#define CLONE_FS  0x00000200  /* set if fs info shared between processes */
#define CLONE_FILES 0x00000400  /* set if open files shared between processes */
#define CLONE_SIGHAND 0x00000800  /* set if signal handlers and blocked signals shared */
#define CLONE_PID 0x00001000  /* set if pid shared */
#define CLONE_PTRACE  0x00002000  /* set if we want to let tracing continue on the child too */
#define CLONE_VFORK 0x00004000  /* set if the parent wants the child to wake it up on mm_release */
#define CLONE_PARENT  0x00008000  /* set if we want to have the same parent as the cloner */
#define CLONE_THREAD  0x00010000  /* Same thread group? */

#define CLONE_SIGNAL  (CLONE_SIGHAND | CLONE_THREAD)

(3)user_struct

// include/linux/sched.h
/*
 * Some day this will be a full-fledged user tracking system..
 */
struct user_struct {
  atomic_t __count; /* reference count */
  atomic_t processes; /* How many processes does this user have? */
  atomic_t files;   /* How many open files does this user have? */

  /* Hash table maintenance information */
  struct user_struct *next, **pprev;
  uid_t uid;
};

   熟悉Unix内核的读者要注意,不要把Unix的进程控制结构中的 user 区与这里的 user_struct 结构相混淆,二者是截然不同的概念。在 kernel/user.c 中还定义了一个 user_struct 结构指针的数组 uidhash_table:


// kernel/user.c
#define UIDHASH_BITS    8
#define UIDHASH_SZ    (1 << UIDHASH_BITS)

static struct user_struct *uidhash_table[UIDHASH_SZ];

    这是一个杂凑(hash)表。对用户名施以杂凑运算,就可以计算出一个下标而找到该用户的 user_struct 结构。

(4)exec_domain

// include/linux/personality.h
/* Description of an execution domain - personality range supported,
 * lcall7 syscall handler, start up / shut down functions etc.
 * N.B. The name and lcall7 handler must be where they are since the
 * offset of the handler is hard coded in kernel/sys_call.S.
 */
struct exec_domain {
  const char *name;
  lcall7_func handler;
  unsigned char pers_low, pers_high;
  unsigned long * signal_map;
  unsigned long * signal_invmap;
  struct module * module;
  struct exec_domain *next;
};

   函数指针 handler,用于通过调用门实现系统调用,我们并不关心。字节 pers_low 为某种域的代码, 有 PER_LINUX、PER_SVR4、PER_BSD 和 PER_SOLARIS 等等。


   我们在这里主要关心的结构成分是 module,这是指向某个 module 数据结构的指针。读者在有关文件系统和设备驱动的章节中将会看到,在 Linux 系统中设备驱动程序可以设计并实现成 “动态安装模块” module,使其在运行时动态地安装和拆除。这些“动态安装模块”与运行中的进程的执行域有密切的关系。例如,一个属于 Solaris 执行域的进程算很可能要用到专门为 Solaris 设置的一些模块,只要还有一个这样的进程在运行,这些为 Solaris 所需的模块就不能拆除。所以,在描述每个一安装模块的数据结构中都有一个计数器,表明有几个进程需要使用这个模块。因此,do_fork() 中通过590行的 get_exec_domain() 递增具体模块的数据结构中的计数器 (定义在include/linux/personality.h 中)。

// include/linux/personality.h
#define get_exec_domain(it) \
  if (it && it->module) __MOD_INC_USE_COUNT(it->module);

Linux 内核源代码情景分析(二)(中):https://developer.aliyun.com/article/1597957

目录
相关文章
|
5天前
|
算法 Linux 调度
深入理解Linux内核调度器:从基础到优化####
本文旨在通过剖析Linux操作系统的心脏——内核调度器,为读者揭开其高效管理CPU资源的神秘面纱。不同于传统的摘要概述,本文将直接以一段精简代码片段作为引子,展示一个简化版的任务调度逻辑,随后逐步深入,详细探讨Linux内核调度器的工作原理、关键数据结构、调度算法演变以及性能调优策略,旨在为开发者与系统管理员提供一份实用的技术指南。 ####
24 4
|
9天前
|
缓存 算法 Linux
深入理解Linux内核调度器:公平性与性能的平衡####
真知灼见 本文将带你深入了解Linux操作系统的核心组件之一——完全公平调度器(CFS),通过剖析其设计原理、工作机制以及在实际系统中的应用效果,揭示它是如何在众多进程间实现资源分配的公平性与高效性的。不同于传统的摘要概述,本文旨在通过直观且富有洞察力的视角,让读者仿佛亲身体验到CFS在复杂系统环境中游刃有余地进行任务调度的过程。 ####
30 6
|
8天前
|
缓存 资源调度 安全
深入探索Linux操作系统的心脏——内核配置与优化####
本文作为一篇技术性深度解析文章,旨在引领读者踏上一场揭秘Linux内核配置与优化的奇妙之旅。不同于传统的摘要概述,本文将以实战为导向,直接跳入核心内容,探讨如何通过精细调整内核参数来提升系统性能、增强安全性及实现资源高效利用。从基础概念到高级技巧,逐步揭示那些隐藏在命令行背后的强大功能,为系统管理员和高级用户打开一扇通往极致性能与定制化体验的大门。 --- ###
29 9
|
7天前
|
缓存 负载均衡 Linux
深入理解Linux内核调度器
本文探讨了Linux操作系统核心组件之一——内核调度器的工作原理和设计哲学。不同于常规的技术文章,本摘要旨在提供一种全新的视角来审视Linux内核的调度机制,通过分析其对系统性能的影响以及在多核处理器环境下的表现,揭示调度器如何平衡公平性和效率。文章进一步讨论了完全公平调度器(CFS)的设计细节,包括它如何处理不同优先级的任务、如何进行负载均衡以及它是如何适应现代多核架构的挑战。此外,本文还简要概述了Linux调度器的未来发展方向,包括对实时任务支持的改进和对异构计算环境的适应性。
25 6
|
8天前
|
缓存 Linux 开发者
Linux内核中的并发控制机制:深入理解与应用####
【10月更文挑战第21天】 本文旨在为读者提供一个全面的指南,探讨Linux操作系统中用于实现多线程和进程间同步的关键技术——并发控制机制。通过剖析互斥锁、自旋锁、读写锁等核心概念及其在实际场景中的应用,本文将帮助开发者更好地理解和运用这些工具来构建高效且稳定的应用程序。 ####
26 5
|
8天前
|
算法 Unix Linux
深入理解Linux内核调度器:原理与优化
本文探讨了Linux操作系统的心脏——内核调度器(Scheduler)的工作原理,以及如何通过参数调整和代码优化来提高系统性能。不同于常规摘要仅概述内容,本摘要旨在激发读者对Linux内核调度机制深层次运作的兴趣,并简要介绍文章将覆盖的关键话题,如调度算法、实时性增强及节能策略等。
|
9天前
|
存储 监控 安全
Linux内核调优的艺术:从基础到高级###
本文深入探讨了Linux操作系统的心脏——内核的调优方法。文章首先概述了Linux内核的基本结构与工作原理,随后详细阐述了内核调优的重要性及基本原则。通过具体的参数调整示例(如sysctl、/proc/sys目录中的设置),文章展示了如何根据实际应用场景优化系统性能,包括提升CPU利用率、内存管理效率以及I/O性能等关键方面。最后,介绍了一些高级工具和技术,如perf、eBPF和SystemTap,用于更深层次的性能分析和问题定位。本文旨在为系统管理员和高级用户提供实用的内核调优策略,以最大化Linux系统的效率和稳定性。 ###
|
8天前
|
Java Linux Android开发
深入探索Android系统架构:从Linux内核到应用层
本文将带领读者深入了解Android操作系统的复杂架构,从其基于Linux的内核到丰富多彩的应用层。我们将探讨Android的各个关键组件,包括硬件抽象层(HAL)、运行时环境、以及核心库等,揭示它们如何协同工作以支持广泛的设备和应用。通过本文,您将对Android系统的工作原理有一个全面的认识,理解其如何平衡开放性与安全性,以及如何在多样化的设备上提供一致的用户体验。
|
7天前
|
缓存 运维 网络协议
深入Linux内核架构:操作系统的核心奥秘
深入Linux内核架构:操作系统的核心奥秘
24 2
|
10天前
|
监控 网络协议 算法
Linux内核优化:提升系统性能与稳定性的策略####
本文深入探讨了Linux操作系统内核的优化策略,旨在通过一系列技术手段和最佳实践,显著提升系统的性能、响应速度及稳定性。文章首先概述了Linux内核的核心组件及其在系统中的作用,随后详细阐述了内存管理、进程调度、文件系统优化、网络栈调整及并发控制等关键领域的优化方法。通过实际案例分析,展示了这些优化措施如何有效减少延迟、提高吞吐量,并增强系统的整体健壮性。最终,文章强调了持续监控、定期更新及合理配置对于维持Linux系统长期高效运行的重要性。 ####