《深入理解Linux内核》学习 — 进程

接着阅读经典了解Linux的进程。

进程描述符

进程描述符:需要把每个进程所做的事情描述清楚,它就保存了这些信息。

进程描述符数据结构是task_struct,进程和进程描述符之间一一对应,Unix操作系统允许用户使用一个叫做进程标识符process ID(PID)的数来标识进程,PID挡在进程描述符的pid字段中。

内核处理进程是,会把进程描述符放在动态内存中,Linux把一个与进程描述相关的小数据结构thread_info(线程描述符)与内核态进程堆栈紧凑的放在一起。

thread_info是task_struct的一个成员变量,同时他的第一个成员变量是task_struct的指针,可以快速访问其进程描述符。

struct task_struct {
    volatile long state;    /* -1 unrunnable, 0 runnable, >0 stopped */
    struct thread_info *thread_info;
  ......
   struct list_head tasks;
    ......
   pid_t pid;
  ......
};

进程链表

进程链表把所有简称的描述符链接起来,每个task_struct包含一个list_head类型的tasks字段,list_head是一个循环双向链表上的节点。

进程链表的头是init_task描述符,就是所谓的0进程。

SET_LINKS往进程链表插入一个进程描述符,REMOVE_LINKS从链表中删除一个进程描述符。

内核再寻找一个新进程在CPU运行时,必须选择TASK_RUNNING(正在运行,可运行)状态的进程。

根据优先级的不同建立了多个(140个)TASK_RUNNING状态进程的可运行队列,每个队列都是一个进程链表,每个CPU都有自己的运行队列。

内核为每个运行队列保存大量数据,主要数据结构就是运行队列的进程描述符,所有链表都由prio_array_t数据结构实现。

struct prio_array {
    unsigned int nr_active;
    unsigned long bitmap[BITMAP_SIZE];
    struct list_head queue[MAX_PRIO];    // MAX_PRIO = 140
};

其余状态的进程不一定会组成进程链表。

对于TASK_STOPPED/EXIT_ZOMBIE/EXIT_DEAD状态的进程不会组织成进程链表。

但对于TASK_INTERRUPTIBLE/TASK_UNINTERRUPTIBLE状态的进程,不光会组成进程链表,还会根据不同的特殊事件分为很多类。

比如有很多进程在等待磁盘操作的种植,这些进程被放进对应的等待队列,一旦磁盘操作中之,队列中的所有进程都会被唤醒。队列也是由双向链表实现。

等待队列由一个等待队列头(wait_queue_head_t)以及队列中的元素构成(wait_queue_t)。

struct __wait_queue {
    unsigned int flags;    // 表示是否互斥
#define WQ_FLAG_EXCLUSIVE   0x01
    struct task_struct * task;
    wait_queue_func_t func;
    struct list_head task_list;
};
struct __wait_queue_head {
    spinlock_t lock;
    struct list_head task_list;
};
typedef struct __wait_queue_head wait_queue_head_t;

避免内核函数同时访问,等待队列头有一个自旋锁。

唤醒的方式有两种:

  1. 内核有选择地唤醒队列中的某一个进程(互斥的情况下,比如临界资源的访问)
  2. 内核在事件发生时唤醒队列中所有进程

有时进程本身也需要获取另一个进程的进程描述符,为了快速获得,设置了一个静态数组pid_hash,他存放着四个hash表,hash值由PID映射而来,hash表的每个元素都是单个简称描述符,或是进程描述符链表(hash值相同)。

进程间的关系

进程描述符中有成员变量表示父进程与兄弟进程。

struct task_struct {
    volatile long state;    /* -1 unrunnable, 0 runnable, >0 stopped */
    struct thread_info *thread_info;
  ......
   struct list_head tasks;
    ......
    pid_t pid;
    ......
    /* 
     * pointers to (original) parent process, youngest child, younger sibling,
     * older sibling, respectively.  (p->father can be replaced with 
     * p->parent->pid)
     */
    struct task_struct *real_parent; /* real parent process (when being debugged) */
    struct task_struct *parent; /* parent process */
    /*
     * children/sibling forms the list of my children plus the
     * tasks I'm ptracing.
     */
    struct list_head children;  /* list of my children */
    struct list_head sibling;   /* linkage in my parent's children list */
  ......
};

进程描述符还包含非亲属关系的进程描述符。

进程切换

为了控制执行,没和必须可以:

  1. 挂起正在CPU上运行的进程
  2. 恢复以前挂起的某个进程并执行

这种行为被称为进程切换/任务切换/上下文切换。

切换时要保存的上下文被称为可执行上下文,而寄存器上的内容被称为硬件上下文。

进程的硬件上下文一部分保存在TSS段,一部分保存在内核态堆栈上。

Intel起初的设计是为每个进程都设置对应的TSS,Linux也会在进程切换时直接切换硬件上下文,但从Linux2.6开始使用软件执行进行切换,通过一组mov指令逐步切换,方便校验。

尽管Linux不使用硬件上下文切换,依然为每个CPU创建了一个TSS,当从用户态访问一个I/O端口,或者切换到内核态需要内核态堆栈地址时,都需要访问TSS。

每次进程切换时,内核都更新TSS的某些字段,因此TSS反映了CPU当前进程的一些信息。

对于每个进程而言,也有一个任务状态段描述符(TSSD),保存在进程描述符的thread成员变量中。

本质上,进程切换就两步:

  1. 切换页全局目录以安装一个新的地址空间
  2. 切换内核态堆栈和硬件上下文

创建进程

三种方式:

  1. 写时复制技术允许父子进程读相同的物理页。只要两者中有一个试图写一个物理页,内核就把这个页的内容拷贝到一个新的物理页,并把这个新的物理页分配给正在写的进程。
  2. 轻量级进程允许父子进程共享进程在内核中的很多数据结构,比如页表(整个用户态地址空间)、打开文件表及信号处理。
  3. vfork()系统调用创建的进程能共享其父进程的内存地址空间。防止防止父进程重写子进程需要的数据,阻塞父进程执行,直到子进程退出或是执行一个新进程。

在Linux中,轻量级进程是由名为clone()的函数创建的,奇怪的是我在linux kernel2.6代码中并未找到与书中参数描述一致的函数。

特地去gpt了一下,解释如下。

SYNOPSIS
      /* Prototype for the glibc wrapper function */

      #define _GNU_SOURCE
      #include <sched.h>

      int clone(int (*fn)(void *), void *child_stack,
                int flags, void *arg, ...
                /* pid_t *ptid, void *newtls, pid_t *ctid */ );

      /* For the prototype of the raw system call, see NOTES */

DESCRIPTION
      clone() creates a new process, in a manner similar to fork(2).

直接man看一下函数声明,具体含义对照书本即可,确实是glibc包装函数的原型。

传统的fork()、vfork()都是由clone()实现的。

在linux内部,由do_fork()来处理clone()、fork()、vfork()的系统调用,利用辅助函数copy_process()来为子进程分配新的PID。

extern long do_fork(unsigned long, unsigned long, struct pt_regs *, unsigned long, int __user *, int __user *);

do_fork实际的工作还是很多的,列出几个需要考虑的条件帮助我们理解为什么需要这么设置流程(具体流程见书本):

  1. 判断父进程是否被别的进程跟踪
  2. 跟踪进程是否想跟踪子进程
  3. 子进程和父进程是否在同一个CPU上运行

copy_process()流程非常长,描述横跨五页,但关键步骤就是dup_stask_struct()与copy_thread(),为子进程获取进程描述符并初始化子进程内核栈。

内核线程

名字上是线程,其实依然是进程,Uinx系统把一些重要的任务委托给在后台周期性执行的进程,这些系统进程之运行在内核态,称之为内核线程。创建内核线程的函数时kernel_thread(),本质还是调用do_fork()。

最经典的内核进程就是进程0与进程1,这在初始化linux的时候会提到。

撤销进程

C编译程序总是把exit()函数插入到main()函数的最后一条语句之后,因为进程终止的一般方式就是调用exiy()。

当然也不光exit()一种系统调用可以终止进程,exit_group()同样可以,可以终止整个线程组。

这里补充一下线程组的概念:线程组基本上就是实现了多线程应用的一组轻量级线程,每个线程都有内核独立调度,可以让它们简单的共享同一地址空间,但注意,对于操作系统来说,依然是进程。同一线程组中线程有相同的PID。

Unix不允许内核在进程一终止后就丢弃包含在进程描述符中的数据,只有父进程发出了与终止的进程相关的wait()类系统调用,才允许这样做。这就是引入僵死状态的原因:尽管技术上来说进程已死,但进程描述符还未删除。

如果父进程在子进程前就结束了,系统中僵死进程会到处都是,必须强迫所有的僵死进程称为Init进程(进程1)的子进程,它会调用wait()。这块设计的很巧妙,学习了~

Linux不同版本多线程的实现

之前文中轻量级进程,线程组概念很多,很可能导致混乱,是由于不同Linux版本对多线程的实现有不同,再次gpt了一下,豁然开朗。

Linux 支持多线程的功能从早期版本就开始存在,但是在不同的 Linux 内核版本中,对多线程的支持和实现方式可能有所不同。以下是一些关键版本和相关发展的重要里程碑:

  1. Linux 2.0:引入了 POSIX 线程标准(也称为 pthreads),提供了对多线程编程的支持。POSIX 线程标准定义了一套多线程编程接口,使得开发人员可以使用标准的 API 来创建和管理线程。
  2. Linux 2.6:引入了 NPTL(Native POSIX Thread Library)作为默认的线程实现库。NPTL 是一个更高效和可扩展的线程库,提供了更好的性能和更好的线程管理。
  3. Linux 2.6.23:引入了进程共享的线程创建模型(CLONE_THREAD 标志),允许在同一进程中创建共享相同虚拟地址空间的线程。这样的线程被称为“轻量级进程”(Lightweight Process)或“线程组”。
  4. Linux 2.6.30:引入了线程本地存储(Thread-Local Storage,TLS)的支持。TLS 允许线程在共享内存中拥有私有的变量副本,提供了一种更高效的线程间数据隔离机制。

需要注意的是,尽管 Linux 内核从早期版本开始支持多线程,但在不同的版本中可能存在一些细微的差异和改进。因此,确保使用最新的稳定版本,以获得更好的多线程支持和性能是推荐的做法。

留下评论