Linux内核初探:进程与线程

前言

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

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

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

阅读本文大概须要三十分钟,阅读之后你会了解到:android

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

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

1.进程与线程

1.1 基本要素

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

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

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

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

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

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

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笨鸟之旅,笨鸟也要有向上飞的心,我在这里陪你一块儿慢慢变强。期待你的关注

相关文章
相关标签/搜索