进程与线程

一般在操作系统的书中都会有这样的定义:

简单来说,进程就是在操作系统中运行的程序,是操作系统资源管理的最小单位。一个进程可以管理多个线程,线程相对轻量,可以共享进程地址空间。

1.1 进程和线程的概念

1.1.1 进程的历史

计算机发明出来是做逻辑运算的,但是当初计算机都是大型机,造价昂贵,只有有钱的政府机构、著名大学的数据中心才会有,一般人接触不到。大家要想用,要去专门的机房。悲催的是,那时候代码还是机器码,直接穿孔把程序输入到纸带上面,然后再拿去机房排队。那时候的计算机也没什么进程管理之类的概念,它只知道根据纸带里的二进制数据进行逻辑运算,一个人的纸带输入完了,就接着读取下一个人的纸带,要是程序有bug,不好意思,只有等到全部运算结束之后才能得到结果,然后回家慢慢改。

为了改进这种排队等候的低效率问题,就有人发明了批处理系统。以前只能一个一个提交程序,现在好了,可以多人一起提交,计算机会集中处理,至于什么时候处理完,回家慢慢等吧。或者你可以多写几种可能,集中让计算机处理,总有一个结果是好的。

懒人总会推动科技进步,为了提升效率,机器码就被汇编语言替代了,从而再也不用一串串二进制数字来写代码了。便于记忆的英文指令会极大提升效率。然后,进程管理这样的概念也被提出来了,为什么要提呢?因为当程序在运算的时候,不能一直占用着CPU资源,有可能此时还会进行写磁盘数据、读取网络设备数据等,这时候完全可以把CPU的计算资源让给其他进程,直到数据读写准备就绪后再切换回来。所以,进程管理的出现也标志着现代操作系统的进步。那么既然进程是运行中的程序,那么,到底什么是程序呢?运行和不运行又有什么区别呢?

先说程序,既然程序是人写的,那么最终肯定会生成可执行文件,保存在磁盘里,而且这个文件可能会很大,有时候不一定是一个文件,可能会有多个文件,甚至文件夹,其包含图片、音频等各种数据。然而,CPU做逻辑运算的每条指令是从内存中读取的,所以运行中的程序可以理解为内存中的代码指令和运行相关的数据被CPU读写并计算的过程。我们都知道内存的大小是有限的,所以很可能装不下磁盘中的整个程序。因此内存中运行的是当下需要运行的部分程序数据,等运算完就会继续读取后面一部分磁盘数据到内存,并继续进行运算。

一个进程在运行的过程中,不可能一直占据着CPU进行逻辑运算,中间很可能在进行磁盘I/O或者网络I/O,为了充分利用CPU运算资源,有人设计了线程的概念。我认为线程最大的特点就是和创建它的进程共享地址空间(关于地址空间的概念大家可以在第3章了解更多)。这时候有人就会认为,要提升CPU的利用率,开多个进程也可以达到,但是开多个进程的话,进程间通信又是个麻烦的事情,毕竟进程之间地址空间是独立的,没法像线程那样做到数据的共享,需要通过其他的手段来解决,比如管道等。图1-1描述了进程和线程的区别。

image-20220414230607715

1.1.2 线程的不同玩法

针对线程现在又有很多玩法,有内核线程、用户级线程,还有协程。下面简单介绍这些概念。

一般操作系统都会分为内核态和用户态,用户态线程之间的地址空间是隔离的,而在内核态,所有线程都共享同一内核地址空间。有时候,需要在内核态用多个线程进行一些计算工作,如异步回调场景的模型,就可以基于多个内核线程进行模拟,比如AIO机制,假如硬件不提供某种中断机制的话,那么就只能通过线程自己去后台模拟了,图1-2说明了有中断机制的写磁盘后回调和没有中断机制的写磁盘后线程模拟异步回调。

image-20220414230655461

在用户态,大多数场景下业务逻辑不需要一直占用CPU资源,这时候就有了用户线程的用武之地。

不管是用户线程还是内核线程,都和进程一样,均由操作系统的调度器来统一调度(至少在Linux中是这样子)。所以假如开辟太多线程,系统调度的开销会很大,另外,线程本身的数据结构需要占用内存,频繁创建和销毁线程会加大系统的压力。线程池就是在这样的场景下提出的,图1-3说明了常见的线程池实现方案,线程池可以在初始化的时候批量创建线程,然后用户后续通过队列等方式提交业务逻辑,线程池中的线程进行逻辑的消费工作,这样就可以在操作的过程中降低线程创建和销毁的开销,但是调度的开销还是存在的。

image-20220414230721994

在多核场景下,如果是I/O密集型场景,就算开多个线程来处理,也未必能提升CPU的利用率,反而会增加线程切换的开销。

另外,多线程之间假如存在临界区或者共享数据,那么同步的开销也是不可忽视的。协程恰恰就是用来解决该问题的。协程是轻量级线程,在一个用户线程上可以跑多个协程,这样就可以提升单核的利用率。在实际场景下,假如CPU有N个核,就只要开N+1个线程,然后在这些线程上面跑协程就行了。但是,协程不像进程或者线程,可以让系统负责相关的调度工作,协程是处于一个线程当中的,系统是无感知的,所以需要在该线程中阻塞某个协程的话,就需要手工进行调度。假如需要设计一套通用的解决方案,那么就需要一番精心的设计。图1-4是一种简单的用户线程上的协程解决方案。

image-20220414231302749

要在用户线程上实现协程是一件很难受的事情,原理类似于调度器根据条件的改变不停地调用各个协程的callback机制,但是前提是大家都在一个用户线程下。要注意,一旦有一个协程阻塞,其他协程也都不能运行了。因此要处理好协程。

1.2 Linux对进程和线程的实现

通过上一节的介绍,大家应该大致了解了进程和线程在操作系统中的概念和玩法,那么对应到具体的Linux系统中,是否就如上面描述的那样呢?下面来分析Linux中对进程和线程的实现。为了便于理解,首先通过图1-5来简单介绍Linux进程相关的知识结构。

image-20220414231611373

从图中可以发现,进程和线程(包括内核线程)的创建,都是通过系统调用来触发的,而它们最终都会调用do_fork函数,系统调用通过libc这样的库函数封装后提供给应用层调用,进程创建后会产生一个task_struct结构,schedule函数会通过时钟中断来触发调度。

1.2.1 Linux中的进程实现

Linux进程的创建是通过系统调用fork和vfork来实现的,参考内核源码/linux-4.5.2/kernel/fork.c:

image-20220414231726986

注意fork和vfork最终都调用do_fork函数,只是传入的clone_flags参数不同而已,参见表1-1。

image-20220414231752340

因为进程创建的核心就是do_fork函数,所以来看一下它的相关参数:

image-20220414231858118

其中:

  • clone_flags:创建子进程相关的参数,决定了父子进程之间共享的资源种类。
  • stack_start:进程栈开始地址。
  • stack_size:进程栈空间大小。
  • parent_tidptr:父进程的pid。
  • child_tidptr:子进程的pid。
  • tls:线程局部存储空间的地址,tls指thread local Storage。

图1-6为do_fork函数的整个执行流程,在这个执行过程当中,比较关键的是调用copy_process函数,成功后创建子进程,然后在后面就可以获取到pid。另外,我们在这里也发现了fork和vfork的一个区别,vfork场景下父进程会先休眠,等唤醒子进程后,再唤醒父进程。大家可以想一想,这样做的好处是什么呢?我个人认为在vfork场景下,子进程被创建出来时,是和父进程共享地址空间的(这个后面介绍copy_process步骤的时候可以进行验证),并且它是只读的,只有执行exec创建新的内存程序映象时才会拷贝父进程的数据创建新的地址空间,假如这个时候父进程还在运行,就有可能产生脏数据或者发生死锁。在还没完全让子进程运行起来的时候,让其父进程休息是个比较好的办法。

image-20220414232022719

现在已经知道了创建子进程的时候,copy_process这个步骤很重要,所以,我用图1-7总结了其主要的执行流程,这段代码非常长,大家可以自己阅读源码,这里只捡重点的讲。copy_process先一模一样地拷贝一份父进程的task_struct结构,并通过一些简单的配置来初始化,设置好调度策略优先级等参数之后,一系列的拷贝函数就会开始执行,这些函数会根据clone_flags中的参数进行相应的工作。

image-20220414232140662

主要参数说明如下:

  1. copy_semundo(clone_flags, p);拷贝系统安全相关的数据给子进程,如果clone_flags设置了CLONE_SYSVSEM,则复制父进程的sysvsem.undo_list到子进程;否则子进程的tsk->sysvsem.undo_list为NULL。
  2. copy_files(clone_flags, p);如果clone_flags设置了CLONE_FILES,则父子进程共享相同的文件句柄;否则将父进程文件句柄拷贝给子进程。
  3. copy_fs(clone_flags, p);如果clone_flags设置了CLONE_FS,则父子进程共享相同的文件系统结构体对象;否则调用copy_fs_struct拷贝一份新的fs_struct结构体,但是指向的还是进程0创建出来的fs,并且文件系统资源是共享的。
  4. copy_sighand(clone_flags, p);如果clone_flags设置了CLONE_SIGHAND,则增加父进程的sighand引用计数;否则(创建的必定是子进程)将父进程的sighand_struct复制到子进程中。
  5. copy_signal(clone_flags, p);如果clone_flags设置了CLONE_THREAD(是线程),则增加父进程的sighand引用计数;否则(创建的必定是子进程)将父进程的sighand_struct复制到子进程中。
  6. copy_mm(clone_flags, p);如果clone_flags设置了CLONE_VM,则将子进程的mm指针和active_mm指针都指向父进程的mm指针所指结构;否则将父进程的mm_struct结构复制到子进程中,然后修改当中属于子进程而有别于父进程的信息(如页目录)。
  7. copy_io(clone_flags, p);如果clone_flags设置了CLONE_IO,则子进程的tsk->io_context为current->io_context;否则给子进程创建一份新的io_context。
  8. copy_thread_tls(clone_flags, stack_start, stack_size, p, tls);其中需要重点关注copy_mm和copy_thread_tls这两个步骤,copy_mm进行内存地址空间的拷贝,copy_thread_tls进行栈的分配。

1.写时复制

copy_mm的主要工作就是进行子进程内存地址空间的拷贝,在copy_mm函数中,假如clone_flags参数中包含CLONE_VM,则父子进程共享同一地址空间;否则会为子进程新创建一份地址空间,代码如下:

image-20220414232321289

dup_mm函数虽然给进程创建了一个新的内存地址空间(关于进程地址空间的概念会在第3章再进行深入分析),但在复制过程中会通过copy_pte_range调用copy_one_pte函数进行是否启用写时复制的处理,代码如下:

image-20220414232409695

如果采用的是写时复制(Copy On Write),若将父子页均置为写保护,即会产生缺页异常。缺页异常最终会调用do_page_fault, do_page_fault进而调用handle_mm_fault。一般所有的缺页异常均会调用handle_mm_fault的核心代码如下:

image-20220414232427224

handle_mm_fault最终会调用handle_pte_fault,其主要代码如下:

image-20220414232445872

即在缺页异常中,如果遇到写保护,则会调用do_wp_page,这里面会处理上面所说的写时复制中父子进程区分的问题。最后通过图1-8来说明fork和vfork在地址空间分配上的区别。

image-20220414232506479

2.进程栈的分配

copy_process中另一个比较重要的函数就是copy_thread_tls,在创建子进程的过程中,进程的内核栈空间是随进程同时分配的,结构如图1-9所示。代码如下:

image-20220414232533485

image-20220414232548773

其中,task_pt_regs(p)的代码如下:

image-20220414232600829

childregs = task_pt_regs(p);实际上就是childregs = ((struct pt_regs *) (THREAD_SIZE +(unsigned long) p)) -1;,也就是说,childregs指向的地方是:子进程的栈顶再减去一个sizeof(struct pt_regs)的大小。

1.2.2 进程创建之后

通过上面的分析我们知道,不管是fork还是vfork,创建一个进程最终都是通过do_fork函数来实现的。在进程刚刚创建完成之后,子进程和父进程执行的代码是相同的,并且子进程从父进程代码的fork返回处开始执行,这个代码可以参考copy_thread_tls函数的实现: