接着阅读经典了解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;
避免内核函数同时访问,等待队列头有一个自旋锁。
唤醒的方式有两种:
- 内核有选择地唤醒队列中的某一个进程(互斥的情况下,比如临界资源的访问)
- 内核在事件发生时唤醒队列中所有进程
有时进程本身也需要获取另一个进程的进程描述符,为了快速获得,设置了一个静态数组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 */
......
};
进程描述符还包含非亲属关系的进程描述符。
进程切换
为了控制执行,没和必须可以:
- 挂起正在CPU上运行的进程
- 恢复以前挂起的某个进程并执行
这种行为被称为进程切换/任务切换/上下文切换。
切换时要保存的上下文被称为可执行上下文,而寄存器上的内容被称为硬件上下文。
进程的硬件上下文一部分保存在TSS段,一部分保存在内核态堆栈上。
Intel起初的设计是为每个进程都设置对应的TSS,Linux也会在进程切换时直接切换硬件上下文,但从Linux2.6开始使用软件执行进行切换,通过一组mov指令逐步切换,方便校验。
尽管Linux不使用硬件上下文切换,依然为每个CPU创建了一个TSS,当从用户态访问一个I/O端口,或者切换到内核态需要内核态堆栈地址时,都需要访问TSS。
每次进程切换时,内核都更新TSS的某些字段,因此TSS反映了CPU当前进程的一些信息。
对于每个进程而言,也有一个任务状态段描述符(TSSD),保存在进程描述符的thread成员变量中。
本质上,进程切换就两步:
- 切换页全局目录以安装一个新的地址空间
- 切换内核态堆栈和硬件上下文
创建进程
三种方式:
- 写时复制技术允许父子进程读相同的物理页。只要两者中有一个试图写一个物理页,内核就把这个页的内容拷贝到一个新的物理页,并把这个新的物理页分配给正在写的进程。
- 轻量级进程允许父子进程共享进程在内核中的很多数据结构,比如页表(整个用户态地址空间)、打开文件表及信号处理。
- 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实际的工作还是很多的,列出几个需要考虑的条件帮助我们理解为什么需要这么设置流程(具体流程见书本):
- 判断父进程是否被别的进程跟踪
- 跟踪进程是否想跟踪子进程
- 子进程和父进程是否在同一个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 内核版本中,对多线程的支持和实现方式可能有所不同。以下是一些关键版本和相关发展的重要里程碑:
- Linux 2.0:引入了 POSIX 线程标准(也称为 pthreads),提供了对多线程编程的支持。POSIX 线程标准定义了一套多线程编程接口,使得开发人员可以使用标准的 API 来创建和管理线程。
- Linux 2.6:引入了 NPTL(Native POSIX Thread Library)作为默认的线程实现库。NPTL 是一个更高效和可扩展的线程库,提供了更好的性能和更好的线程管理。
- Linux 2.6.23:引入了进程共享的线程创建模型(CLONE_THREAD 标志),允许在同一进程中创建共享相同虚拟地址空间的线程。这样的线程被称为“轻量级进程”(Lightweight Process)或“线程组”。
- Linux 2.6.30:引入了线程本地存储(Thread-Local Storage,TLS)的支持。TLS 允许线程在共享内存中拥有私有的变量副本,提供了一种更高效的线程间数据隔离机制。
需要注意的是,尽管 Linux 内核从早期版本开始支持多线程,但在不同的版本中可能存在一些细微的差异和改进。因此,确保使用最新的稳定版本,以获得更好的多线程支持和性能是推荐的做法。