Linux的进程与线程、进程管理

1 Linux中进程、线程、内核线程的概念

所谓进程,就是运行中的程序,没运行的程序只是一段固定的代码,运行中的程序不仅包括代码,还包括运行程序需要用到的资源、程序运行的状态等等。进程还经常被称为任务,一般用户角度来看叫做进程,内核角度来看叫做任务。

在一般的操作系统角度看,进程和线程是两种实体,操作系统对进程和线程有两套不同的处理流程,比如两套用于表示进程和线程信息的数据结构,两套调度算法等等。普遍认为,进程是操作系统管理机器资源的基本单位,线程是程序运行的基本单位,即进程是资源容器,线程是执行单元。

但在Linux系统中(不包括传统UNIX系统),并不对线程和进程进行严格区别,而是把线程当作一种特殊的进程。具体体现在Linux并没有为线程单独定义表示线程的数据结构(比如进程描述符、线程描述符),线程和进程都用的一个结构,就是task_struct,也没有为线程专门定制一套线程的调度策略。

当然,并不是说Linux就完成没有线程这个概念,全是进程来运行,不是这样的。其他的操作系统区分进程和线程的原因之一在于它们的进程切换和共享的开销很大,所以用更加轻巧的线程,在传统的操作系统中线程也叫做轻量级进程(这里存疑,在《深入理解Linux内核》中P85说Linux的线程就是所谓的轻量级进程,但在《Linux内核设计与实现》P29中说其他的系统常常将线程叫做轻量级进程)。但是Linux本身由于它的独特优势,其进程切换、创建等就很快了,所以才有底气把进程当作线程处理。在很多介绍Linux内核的书籍中,仍然会用进程和线程两个概念来说明,是因为在Linux中其实还是有进程和线程的细微区别的,它们只是有很大部分是一样的,并不是完全一样。

现在我们明白Linux中线程基本等于进程,Linux中线程分为两种,用户线程和内核线程,再次说明这两个线程基本都等于进程。
所谓内核线程,是指内核经常需要在后台执行一些操作,比如刷新磁盘高速缓存,交换出不用的页框,维护网络连接等等(不理解所谓后台到底是什么,后台操作有什么不同),这些操作可以由内核线程来完成。内核线程和用户进程(线程)的区别在于没有独立的进程地址空间,它只在内核空间运行,从不切换到用户空间。内核线程和普通进程一样,可以被调度,可以被被抢占。

2 Linux中进程、用户线程、内核线程的创建

2.1 进程的创建

Linux内核上电后第一个用户进程(注意是用户进程,在此之前已经创建了内核线程0,这里可以结合Linux的启动过程来分析)是init进程,init进程PID为1,init进程自此会运行其他程序,根据《现代操作系统》P426的讲解,init进程检测它的标志以确定它应该为单用户还是多用户服务,如果是单用户(嵌入式一般都是单用户),init进程将调用fork函数创建一个shell进程,并等待shell进程结束(这一部分不太透彻,回去仔细研究)。比如我们在shell终端中键入ls,则shell会新创建一个ls进程用来执行ls这个程序,就像busybox一样,ls、cd这些命令实际上都是一个个可执行程序ls.c、cd.c。

在Linux系统中通过fork系统调用创建一个与原始进程完全相同的进程副本。父子进程有不同的内存映像(也即是不同的进程地址空间,也就是不同的页表,也就是子进程可以在自己的内存映像里从外存中装载自己要运行的程序代码),父子进程对变量的修改对双方都是不可见的,也就是不是共享的。

父子进程可以共享已经打开的文件,如果父进程在调用fork创建子进程之前打开了文件A,且没关闭,那么子进程也相当于打开了文件A,父子对文件A的修改是共享的。

之前说了,子进程在fork调用之后完全是父进程的一个副本,虽然有自己的内存映像,但里面的东西是和父进程完全一样的。实际上,父子进程的内存映像、 变量、寄存器以及其他所有东西都是相同的。父进程调用fork后,子进程是父进程的副本,子进程开始运行,会执行父进程fork调用的下一条语句(实际上是因为子进程复制父进程所有内容,包括代码和程序计数器PC,父进程调用fork后程序计数器的值自然是下一条语句)。那么问题来了,在编写代码时如何避免子进程和父进程执行完成一样的代码呢(这样没有意义)?答案在于fork调用一次会返回两次,一次将子进程的PID返回给父进程,一次返回0给子进程,因此在编写代码时可以这么写:

//这是父进程代码,子进程会复制这段代码
pid=fork();
if(pid<0)
error;//
else if(pid>0){
/*父进程需要执行的代码*/
}
else{
/*子进程需要执行的代码*/
}

通常子进程执行的是和父进程不一样的代码,以shell进程为例,它从终端读取一行命令,比如为ls,shell调用fork生成一个子进程,然后通过调用waitpid原地等待子进程来执行ls这个命令(也就是说shell父进程的代码是一直等待),也就是等待子进程执行完ls.c,子进程结束后继续读取下一条命令(这里子进程怎么自己结束呢?不太清楚)。将shell通过fork调用产生的子进程命名为ls进程,ls进程的代码中(目前还是全部复制的shell的代码)会马上进行exec调用,exec调用的三个参数分别为:待执行的文件名字,执行所需的参数、环境变量(exec调用的细节,以及需要什么参数,何为环境变量,请看《现代操作系统》P418-419)。其中ls进程待执行的文件名字可能就是ls.o。

调用exec以后,内核会找到并核实相应的可执行文件(这里是ls.c编译出来的可执行文件),把传过来的参数和环境变量复制到内核(复制到内核地址空间吗),释放原本从父进程那里复制的地址空间和页表(其实这个时候根本没有子进程专属的空间和页表,参考下面的写时复制机制),建立并填充新的地址空间,建立新的页表。之前传过来的参数和环境变量会复制到新的地址空间的新的栈中,所有信号会被重置,寄存器被全部清0(包括程序计数器PC,这样子进程的执行流就不会和父进程一样了,因为之前PC也是从父进程那里复制的,PC清0后会从头开始执行ls.c的可执行程序)。这样调用exec以后,会将ls.c的代码内容装载到ls子进程的内存映像中,这样ls子进程就开始执行自己需要完成的工作了(ls.o这个文件在编译时有像韦东山第一期那样指定代码段数据段吗,它装载到进程地址空间时,内核怎么将ls.o分配到进程地址空间的数据段,代码段等等)。下图时shell实现ls的示意图:
在这里插入图片描述
下面的代码是一个高度简化的shell,用以说明上面的shell执行逻辑:

while(TURE){/*shell一直重复*/
	type_prompt();/*在屏幕上显示提示符*/
	read_command(command,params);/*从键盘读入输入行*/
	pid=fork();
	if(pid<0)
	printf("unable to fork");
	continue;
	else if(pid>0){
		waitpid(-1,&status,0);/*shell等待命令行对应的进程执行完毕*/
	}
	else{
	execve(command,params,0)/*子进程执行对应操作*/
	}
}

这里还有一个点就是写时复制,前面说了在父进程调用fork以后,子进程相当于是父进程的复制品,但是将父进程的所有东西复制给子进程是很耗时的,而且不是非常必要,因为子进程往往会自己调用exec执行自己的东西。所以现在Linux系统采用了写时复制的技术,它赋予子进程属于子进程的页表,但这些页表都指向父进程的物理页面,同时把这些页面标记为只读。当子进程试图向某个页面中写入数据时,它会收到写保护的错误。内核发现子进程的写入行为之后,才会为子进程分配一个该页面的新副本,并将这个副本标记为可读可写,子进程这时才可以使用。大多数情况下,子进程创建后都会马上exec,所以fork的实际开销就是复制父进程的页表以及给子进程创建唯一的进程描述符。子进程的用户态堆栈在最初也是和父进程共享的,直到子进程试图去写入堆栈。

2.2 用户线程的创建

2000年的时候,Linux引入了clone系统调用,模糊了进程和线程的区别,clone系统调用可以用来创建线程,也可以用来创建进程(进程和线程的区别在于某些资源是否共享,这将通过clone的参数来确定),实际上fork调用也是用clone调用实现的。clone的调用方式如下:

pid=clone(function,stack_ptr,sharing_flags,arg);

调用clone可在当前进程中创建一个线程,或者新建一个进程,具体依赖于sharing_flags的取值。如果是新建一个线程,它将于该进程下其他线程共享地址空间,如果是新建一个进程,则获得原始进程的地址空间的拷贝,类似fork。不管是新建线程还是进程,调用结束后都从function处开始执行(这和fork不同,fork虽然也是由clone实现,但应该做了其他处理,比如复制PC,使得新进程从fork下一句开始执行),arg是function执行所学的参数,新创建的线程或进程有自己的私有堆栈,stack_ptr指向该堆栈。

参数sharing_flags是一个位图,每一位可以单独设置,且每一位决定了新线程是复制一些数据结构还是与调用clone的线程共享这些数据结构(这里把进程线程统一称为线程了)。具体如下图所示,每个位的具体描述在《现代操作系统》423:
在这里插入图片描述
在Linux系统中,通过fork创建一个进程后,该进程自带一个线程,就是它自身,如果该进程clone创建新的线程,则该进程下包含了多个线程,第一个线程称为主线程(也就是调用clone的那个线程),或者头部线程。该进程下面的几个线程(包括主线程)称为一个线程组,实际上传统意义上的进程里面就是包含线程组,但是在Linux里面线程组上面并没有一个更高级的称为进程的东西。线程组中所有线程的PID和主线程相同,每个线程有不同的TID。

2.3 内核线程的创建

内核线程是一种只运行在内核地址空间的线程。所有的内核线程共享内核地址空间(对于 32 位系统来说,就是 3-4GB 的虚拟地址空间),所以也共享同一份内核页表。这也是为什么叫内核线程,而不叫内核进程的原因。内核线程只能由内核线程创建,创建函数位kernel_thread(),该函数本质上调用do_fork()函数。实际上fork()、vfork()、clone()等函数,都是调用了系统调用clone,clone又是由do_fork()函数来处理的(不明白为什么一个系统调用最终是通过某个函数实现的,do_fork()是内核函数,不明白内核函数啥意思)。

所有进程的祖先叫做进程0,idle进程,或者由于历史原因叫做swapper进程,它是Linux的初始化阶段从无到有建立的一个内核线程。所有的内核线程都是由它而始。

相关其他信息没写全,参考这篇文章:https://www.bilibili.com/read/cv16834316 这篇文章不错,可以看看

3 Linux中进程、用户线程、内核线程的调度

同一线程组的线程,哪些是共享的,哪些是私有的

每个线程都私有的是:
(1)ask_struct、struct thread_info
(2)用户栈、内核栈、数据段(《现代操作系统》429说Linux进程从不共享栈和数据段)、存放变量的寄存器
(3)
每个线程都共享的是:
(1)进程地址空间(不太准确,按理说进程地址空间包括用户栈,这里应该是共享正文段,或者说共享的是正文段页表)
(2)用户堆

参考文档

https://blog.csdn.net/u010936265/article/details/116742794
https://blog.csdn.net/Qiuzhongweiwei/article/details/125928388

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值