Linux内核初探:进程与线程

5,631

前言

Hi,各位朋友们,咱们又见面了。本月我的工作和生活出现了一些变动,我现在也在进行积极的调整来适应变动,后续会做更多的努力来维持之前的学习和发文节奏。话不多说,今天我们来聊一聊Linux内核中的进程和进程调度。之前学习操作系统的时候,虽然知道一些操作系统的基本设计思想,对于Windows和Linux的实际应用还是不够了解,尤其是在进程调度这一至关重要的方面,不明白两种操作系统的具体差异。Android又是基于Linux内核的,了解一点内核知识总是没错的,嘿嘿。本篇为Linux内核初探系列第一篇,后续会陆续有其余方面的文章。

当然,Linux内核博大精深,我也只是通过阅读源码和书籍管中窥豹。如果有什么写的不对的地方,欢迎各位朋友指正。本文基于Linux 2.6.25版,在这个版本中Linux已经加上了CFS调度策略,同时代码量也不会很大,比较适合阅读。源码下载地址:mirrors.edge.kernel.org/pub/linux/k…

可能有些朋友会觉得看这些内容没啥用,那么你可以先看看这篇文章。Android 平台 Native 代码的崩溃捕获机制及实现

阅读本文大概需要三十分钟,阅读以后你会了解到:

  • Linux中进程基本要素
  • 进程描述符和分配
  • 进程状态和变更
  • 进程家族树
  • 内核态与用户态
  • 进程创建
  • 线程和进程的联系及差异

弱弱的求个点赞和关注,给小笨鸟一点写作的动力。文章首发公众号: Android笨鸟之旅。更多技术咨讯文章,敬请关注。

1.进程与线程

1.1 基本要素

要给进程下一个明确的定义可能不是件容易的事情,不过一般来说都认为进程是处于运行期的程序和相关资源的总称,具备一些要素:

  • 拥有一段可执行程序代码。就像一场戏需要一个剧本。代码段可以多个进程共用,就像许多场演出都可以用一份剧本一样。
  • 拥有一段进程专用的系统堆栈空间。可以认为是这个进程的“私有财产”,相应的,也肯定有系统空间堆栈
  • 在系统中有进程控制块(或称进程描述符, 本文两种说法通用)描述这个进程的相关信息。可以认为是进程的“户口”。系统通过这个控制块来控制进程的相关行为
  • 有独立的存储空间,也就是专有的用户空间,相应的又会有用户空间堆栈。

这四条都是进程的必要条件。而进程通常含有一个或多个执行线程,线程是在进程中活动的对象,也是内核调度的基本对象。可能大家会有疑问,进程已经可以独立的拥有运行程序和资源了,已经可以完成相关的任务了啊,为什么还要引进线程的概念,并把线程作为内核调度的基本对象呢?

引入进程的原因,是因为同一个进程,内部可能存在多个不同的task,这些task需要共享进程的数据,但是这些task操作的数据又有一定的独立性,因此多个task并不需要按照时序执行,因此就产生了线程的概念。以office word写文章为例,office word程序的运行就是一个进程,但是进程只能把它运行起来,而word还要检测你光标的移动,进行纠错等相关功能,那么光标移动和进行纠错就是不同的线程,都需要CPU根据不同的策略来进行调度。因此,线程被引入并作为内核调度的基本对象

Linux系统对于线程实现非常特殊,他并不区分线程和进程,线程只是一种特殊的进程罢了。从上面四点要素来看,拥有前三点而缺第四点要素的就是线程,如果完全没有第四点的用户空间,那就是系统线程,如果是共享用户空间,那就是用户线程

因为线程只是特殊的进程,我们会以进程知识为主,最后讲解Linux下线程与进程的区别。

1.2 进程描述符

内核把进程的列表存放在称为任务队列的双向循环链表中。链表的每一项都是类型为task_struct, 称为进程描述符, 定义于<linux/sched.h>中。进程描述符包含一个进程的所有信息,包括的数据相当多,比如进程的状态,打开的文件,挂起的信号,父子进程等,所以大小相对较大。

下面是部分比较重要的属性的定义。

struct task_struct {
	volatile long state;	// -1为不可运行, 0为可运行, >0为已中断
	int lock_depth;		// 锁的深度
    unsigned int policy; // 调度策略:一般有FIFO,RR,CFS
	pid_t pid;   // 进程标识符,用来代表一个进程
	struct task_struct *parent;	// 父进程
	struct list_head children;	// 子进程
	struct list_head sibling;   // 兄弟进程
}

从2.6版本以后,Linux改用了slab分配器动态生成task_struct, 只需要在栈底(向下增长的栈)或栈顶(向上增长的栈)创建一个新的结构struct thread_info(这里是栈对象的尾端),你可以把slab分配器认为是一种分配和释放数据结构的优化策略。通过预先分配和重复使用task_struct, 可以避免动态分配和释放带来的资源消耗。也因此,进程创建迅速是Linux系统的一个重要特点。

slab分配器把不同对象类型划分为不同高速缓存组,比如一个高速缓存用于存放进程描述符task_struct,另外一个存放索引节点对象inode。这些高速缓存又会被划分为slab,slab由一个或多个物理上连续的页组成。当要申请数据结构的时候,比如我们要申请一个task_struct,会先从半满的slab(slabs_partial)中申请,如果没有半满的,就去空的slab(slabs_empty)中申请,直到把所有slab填满(slabs_full)为止。如果空slab也没有了,那就要申请一个新的空slab。这种策略能减少数据结构频繁申请和释放的内存碎片,并且由于有了缓存,分配和释放迅速。

我们继续看thread_info这个结构。定义于<asm/thread_info.h>

struct thread_info {
	struct task_struct	*task;		/* main task structure */
	struct exec_domain	*exec_domain;	/* execution domain */
	unsigned long		flags;		/* low level flags */
	__u32			cpu;
	int			preempt_count; /* 0 => preemptable, <0 => BUG */
	mm_segment_t		addr_limit;	/* thread address space */
	struct restart_block	restart_block;
};

在内核中,操作进程都需要获得进程描述符task_struct的指针,所以获取task_struct的速度就显得尤为重要,有的硬件体系会拿出专门寄存器来存放当前task_struct的指针,有些寄存器不富余的体系就只能在栈的尾端创建thread_info结构,通过计算来间接查找。

1.3 进程状态

我们前面说过,进程描述符描述了进程的当前状态,包括了进程的所有信息。进程描述符中的state字段就描述了进程的当前状态。我们可以在<kernel/include/linux/sched.h>中找到进程状态取值的定义。不同的系统版本可能会有差异,常用的几种状态有:

#define TASK_RUNNING		0
#define TASK_INTERRUPTIBLE	1
#define TASK_UNINTERRUPTIBLE	2
#define __TASK_STOPPED		4
#define __TASK_TRACED		8
/* in tsk->exit_state */
#define EXIT_ZOMBIE		16
#define TASK_DEAD		64

系统中每个进程都必然处于7种进程状态之一:

  • TASK_RUNNING(运行): 进程是可执行的,它可能正在执行,或者在运行队列中等待执行。也就是说不管有没有执行,只要它可执行,那就是处于TASK_RUNNING态。同一时刻可能有多个进程处于可执行态,他们被放在一个运行队列中等待进程调度器调度。

  • TASK_INTERRUPTIBLE(可中断):进程正被阻塞,等待某些条件达成。当这些条件达成后内核就会把进程状态设置为运行,处在此状态的进程也会因为接收到信号而提前唤醒并随时准备运行。我们可以通过ps命令查看,可以看到系统其实大部分进程都在沉睡。

  • TASK_UNINTERRUPTIBLE(不可中断):就算接收到信号也不会被唤醒或准备投入运行,较之可中断状态用得较少。不可中断,指的并不是CPU不响应外部硬件的中断,而是指进程不响应异步信号。TASK_UNINTERRUPTIBLE状态存在的意义就在于,内核的某些处理流程是不能被打断的。比如内核跟硬件交互的时候,为了避免进程与设备交互的过程被打断,造成设备陷入不可控的状态,可能就需要这种状态。

  • __TASK_TRACED: 被其他进程跟踪的进程,比如通过ptrace对调试程序进行跟踪。咱们日常开发中使用断点,会发现进程停留在咱们断点所在的位置,这个时候进程就是__TASK_TRACED态。这种状态下的进程只能等到调试进程通过ptrace系统调用执行PTRACE_CONT、PTRACE_DETACH等操作才能继续恢复到TASK_RUNNING态。

  • __TASK_STOPPED: 进程停止执行;没有投入运行也不能投入运行。通常发生在接受到SIGSTOP, SIGTSTP, SIGTTIN, SIGTTOU等信号的时候。这种暂停的状态和__TASK_TRACED比较相似,向进程发送一个SIGCONT信号,可以让其从TASK_STOPPED状态恢复到TASK_RUNNING状态。

  • TASK_DEAD: 进程退出态,进程即将被销毁。

  • EXIT_ZOMBIE/TASK_ZOMBIE:进程已经结束但是进程控制块task_struct还没注销。这个状态需要和上面的TASK_DEAD状态一起来看,进程在退出的过程中处于TASK_DEAD态。在这个退出过程中,进程占有的所有资源将被回收,但是父进程很可能会关心这个进程的一些信息,于是携带这些信息的task_struct结构就没有被销毁。

状态之间的切换关系如图:

可以看到状态虽然有多种,但是变迁方向永远是两种:

  • TASK_RUNNING -> 非TASK_RUNNING态
  • 非TASK_RUNNING -> TASK_RUNNING态

也就是说,就算进程在TASK_INTERRUPTIBLE态被kill掉,他也需要先唤醒进入TASK_RUNNING态再响应kill信号进入TASK_DEAD态。

1.4 进程家族树

Linux系统中的进程存在一个明显的继承关系。所有的进程都是PID为1的init进程的后代。内核会在系统启动的最后阶段启动init进程,init进程再读取系统的初始化脚本最终完成系统启动的整个过程。

系统中的每个进程都必有一个父进程,每个进程也可以有零个或多个子进程,当然因此每个进程也会有多个兄弟进程。进程间的关系存放于进程描述符task_struct中。我们在1.2节中讲到了task_struct中有下面三个属性:父进程,子进程和兄弟的list。

	struct task_struct *parent;	// 父进程
	struct list_head children;	// 子进程
	struct list_head sibling;   // 兄弟进程

因为这种继承体系,我们可以通过指针的方式从任何一个进程出发查到任意指定的其它进程。

1.5 内核态与用户态

我们都知道,Linux系统内核其实就是一种特殊的软件程序,特殊在哪儿呢?控制计算机的硬件资源,例如协调CPU资源,分配内存资源,并且提供稳定的环境供应用程序运行。而应用程序是在内核的调度下完成自己的任务。

从系统设计的角度上来说,内核应该要有对系统所有资源和操作进行控制的能力,而应用程序访问资源和进行各种操作都应该在系统的允许范围内,这样才能保证系统平稳安全运行。因此,Linux的涉及哲学之一就是:为不同的操作赋予不同的执行等级,与系统相关的一些特别关键的操作必须由最高特权的程序来完成。对应的就是内核态和用户态。运行于用户态的进程可以执行的操作和访问的资源都会受到极大的限制,只能使用他们允许范围内的资源和功能,而运行在内核态的进程则可以执行任何操作并且在资源的使用上没有限制。

从内存使用角度来看,两种状态分别有内核空间和用户空间。每个进程都有4G的虚拟寻址空间,这4G地址空间中,最高1G都是一样的,即内核空间。只有剩余的3G才归进程自己使用。也就是说,这1G的空间是所有进程共享的。当进程运行在内核空间时就处于内核态,而进程运行在用户空间时则处于用户态。。同时,在这两个空间中还分别有一个系统堆栈和用户空间堆栈,进程运行在不同的态下就使用不同的堆栈。运行于内核空间时,CPU可以执行任何指令。运行的代码也不受任何的限制。进程运行在用户地址空间时,那就要像大人管着的小孩,乖乖的了。各位看官,看到这里对于我们1.1节的基本要素是不是更理解了呢。

那可能大家会有问题了,为啥进程需要有内核态呢,乖乖的运行于用户态,管自己的一亩三分地不好吗?其实是不行的,因为进程的功能和内核息息相关(没办法,被限制的太死了),比如我们常见的printf函数,虽然是应用程序发起,但是它需要进入内核态才能把数据写到控制台上。我们也称应用程序在内核空间运行, 或者内核运行于进程上下文,或者陷入内核空间。这种交互方式是应用程序完成其工作的基本行为方式。

那么有哪些从用户态进入到内核态的方式呢?一般有三种

  • 通过系统调用进入,比如我们上面例子中printf就是调用write函数
  • 通过软中断进入,常见的是进程突然发生了异常。比如android中的应用crash发生以后,进程就会进入内核态调用中断服务。
  • 通过硬件中断进入,通常是外部设备的中断。当外围设备完成用户的请求操作后,会像CPU发出中断信号,此时,CPU就会暂停执行下一条即将要执行的指令,转而去执行中断信号对应的处理程序,如果先前执行的指令是在用户态下,则自然就发生从用户态到内核态的转换。比如网卡,键盘等,你一打字,进程就会陷入到内核态,是不是很奇妙。

上面所说到的应用通过软中断和硬中断进入内核态以后,都会去查找和调用相对应的中断服务程序。Linux的中断服务程序都不在进程上下文中执行,而是有一个进程无关的中断上下文中运行,保证中断服务程序能第一时间响应和处理,然后快速退出。

所以进程,或者说CPU,在任何指定时间点上的活动必然为三者之一:

  • 运行于用户空间,执行用户进程
  • 运行于内核空间,处于进程上下文,代表某个特定进程执行
  • 运行于内核空间,处于中断上下文,与任何进程无关,处理某个特定中断。

1.6 小节总结

本节先介绍了进程的基本要素,进程描述符的相关知识和进程不同状态的切换,然后介绍了进程家族树和进程的内核态和用户态。大家看完第一节应该对进程的工作方式有初步的理解,我们第二节会更近一步,从介绍进程创建引入,介绍Linux下进程和线程的联系和区别。给自己打打气,咱们继续!

2.进程创建及线程

2.1 进程创建

Linux的进程创建很特别,当然也是因为继承了Unix的缘故。很多别的操作系统比如windows都提供了创建进程的机制,首先在新的地址空间里创建进程,读入可执行文件,然后开始执行,这些步骤可能是通过一个方法完成的。但是Unix把上述步骤分到两个单独函数中去,合并使用和其他系统的单一函数效果一致,这两个函数是。

  • fork函数:通过拷贝当前进程创建一个子进程,子进程和父进程的区别就只在于PID(进程id)和PPID(父进程id)和少量资源
  • exec函数:负责读取可执行文件并载入地址空间开始运行。通常是指exec函数族。

这种设计体现了Unix的设计哲学,现在来看也是比较符合单一职责原则的,毕竟通常情况下父子进程需要做的事情(可执行文件)都是不同的。

前面我们也提到过,Unix的一个特点就是创建和释放进程相当迅速。其实在fork函数上也有体现。fork函数承担的责任是是让创建出来的子进程拥有父进程的所有资源。传统的fork函数会把父类的所有资源复制给新资源,这种实现过于简单和低效,因为很多情况下这些拷贝会失去意义,比如这些数据并不共享,或者子进程用不上这些数据。Linux的fork函数使用了写时拷贝来进行优化。Linux创建子进程的时候,内核并不复制父进程地址空间,而是让子进程直接以只读方式共享父进程空间数据,大家可以想象为一个指针指向了原来的地址,等到子进程需要写这些数据的时候,数据才会被拷贝到子进程。这样就可以推迟甚至免除拷贝数据了。

接下来我们通过代码层面来理解进程创建过程。

2.2 fork与vfork

单纯从代码层次来看,Linux有两种不同的函数来创建进程:fork函数,vfork函数。两个函数都是从父进程拷贝出一个新进程,但是也有区别。下面是fork和vfork的定义。定义于<kernel/fork.c>中。本段代码源于kernel 4.4版本。

//fork系统调用
SYSCALL_DEFINE0(fork)
{
  return do_fork(SIGCHLD, 0, 0, NULL, NULL);
}

SYSCALL_DEFINE0(vfork)
{
  return _do_fork(CLONE_VFORK | CLONE_VM | SIGCHLD, 0,
      0, NULL, NULL, 0);
}

long do_fork(unsigned long clone_flags,
        unsigned long stack_start,
        unsigned long stack_size,
        int __user *parent_tidptr,
        int __user *child_tidptr)
{
  return _do_fork(clone_flags, stack_start, stack_size,
      parent_tidptr, child_tidptr, 0);
}

long _do_fork(unsigned long clone_flags,
        unsigned long stack_start,
        unsigned long stack_size,
        int __user *parent_tidptr,
        int __user *child_tidptr,
        unsigned long tls)
{
	// .... 省略大量代码
	p = copy_process(clone_flags, stack_start, stack_size,
       child_tidptr, NULL, trace, tls);
	// .... 省略大量代码
}

我们可以看到fork和vfork最终都是通过调用do_fork来实现的,只不过传入的参数值不一致。第一个参数中传入了一些flag,并且这个flag最终被copy_process所使用。copy_process这个方法是真正的执行拷贝的地方,有兴趣的同学可以研究研究。那么这些flag有什么含义呢?下面是linux中的flag定义(定义于<include/linux/sched.h>)。

参考这里传入的Flags的区别,我们可以进一步给出结论:

  1. fork会拷贝父进程的页表,而vfork永远不会复制,直接让子进程共用父进程的页表(页表实现从页号到物理块号的地址映射)。这是vfork优于当前的fork的地方
  2. vfork创建完子进程后,子进程作为父进程的线程在地址空间中运行,同时阻塞父进程,直到子进程退出或者执行exec()。并且子进程不能像地址空间写入。这一点在fork没有支持写时拷贝前是很有意义的,但是由于fork后来引入了写时拷贝页并且明确了子进程先执行,vfork的好处就只限于不拷贝页表项了。

2.3 线程

创建线程和创建普通的进程类似,只不过需要在调用do_fork的时候需要传递不同的flag来指明需要共享的资源。使用pthread_create方法来进行创建,最终会调用到do_fork方法。传入的flag为

CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND

从上面的flag我们可以看出,创建出来的线程从内核层面来看其实也是一种特殊的进程,它跟父进程共享了打开的文件和文件系统信息,共享了地址空间和信号处理函数,这也跟我们传统印象中的线程和进程的关系是符合的。Linux的实现和windows之类的操作实现差异很大,假设一个进程有四个线程,在其它提供了专门线程支持的系统中,系统会在进程中增加一个包含指向该进程所有线程的指针的进程描述符,每个线程再去描述自己独占的资源,但是linux就仅仅创建四个进程并分配四个普通的进程描述符,指定他们共享某些资源,这样更为简单。

线程又分为内核线程用户线程,内核线程是独立运行于内核空间的标准进程,他们没有独立地址空间,从来不会切换到用户空间去。用户线程就是咱们所认知的普通线程了。

2.4 小节总结

本节介绍了进程创建的相关知识,从进程创建的角度介绍了线程和进程的区别。接下来我们要进入一个新的知识点,那就是CPU的调度策略,也就是我们熟知的CPU运行时间分配。

全文总结

本文介绍了进程和线程相关的知识,本来想把进程调度相关的内容也包括进来,但是限于篇幅过长拆成了两篇文章,在此卖个关子,Linux内核的进程调度跟我们之前操作系统课上学的区别很大,个人感觉很优雅很有意思。有兴趣的同学可以继续关注我的下一篇文章《Linux内核初探:进程调度》。

参考

《Linux内核设计和实现》

man手册

Linux探秘之用户态与内核态

Gityuan

我是Android笨鸟之旅,笨鸟也要有向上飞的心,我在这里陪你一起慢慢变强。期待你的关注