精通Linux内核开发

978-7-115-56604-1
作者: 拉古·巴拉德瓦杰(Raghu Bharadwaj)
译者: 白浩文文平波
编辑: 傅道坤

图书目录:

详情

《精通Linux内核开发》介绍了Linux内核、内核的内部编排与设计,以及内核的各个核心子系统等知识。本书分为11章,具体内容包括:进程、地址空间和线程;进程调度器;信号管理;内存管理和分配器;文件系统和文件I/O;进程间通信;虚拟内存管理;内核同步和锁;中断和延迟工作;时钟和时间管理;模块管理。 《精通Linux内核开发》篇幅短小精悍,通过大量代码辅助介绍Linux内核的相关开发工作。通过学习本书,读者可以深入理解Linux内核的核心服务与机制,了解这个集中了集体智慧的Linux内核在保持其良好设计的同时,是如何保持其优雅特性的。 《精通Linux内核开发》适合Linux内核开发人员、底层开发人员阅读,还适合希望深入理解Linux内核及其各组成部分的系统开发人员学习。高校软件工程专业的学生也可以将本书当作了解Linux内核设计原理的参考指南。

图书摘要

版权信息

书名:精通Linux内核开发

ISBN:978-7-115-56604-1

本书由人民邮电出版社发行数字版。版权所有,侵权必究。

您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。

如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。


版  权

著    [印度]拉古·巴拉德瓦杰(Raghu Bharadwaj)

译    白浩文 文平波

责任编辑 傅道坤

人民邮电出版社出版发行  北京市丰台区成寿寺路11号

邮编 100164  电子邮件 315@ptpress.com.cn

网址 http://www.ptpress.com.cn

读者服务热线:(010)81055410

反盗版热线:(010)81055315

内 容 提 要

本书介绍了Linux内核、内核的内部编排与设计,以及内核的各个核心子系统等知识。本书分为11章,具体内容包括:进程、地址空间和线程;进程调度器;信号管理;内存管理和分配器;文件系统和文件I/O;进程间通信;虚拟内存管理;内核同步和锁;中断和延迟工作;时钟和时间管理;模块管理。

本书篇幅短小精悍,通过大量代码辅助介绍Linux内核的相关开发工作。通过学习本书,读者可以深入理解Linux内核的核心服务与机制,了解这个集中了集体智慧的Linux内核在保持其良好设计的同时,是如何保持其优雅特性的。

本书适合Linux内核开发人员、底层开发人员阅读,还适合希望深入理解Linux内核及其各组成部分的系统开发人员学习。高校软件工程专业的学生也可以将本书当作了解Linux内核设计原理的参考指南。

关 于 作 者

Raghu Bharadwaj是Linux内核领域的资深顾问、贡献者兼企业培训师,具有近20年的从业经验。他是一个狂热的内核爱好者和专家,自20世纪90年代后期以来就一直密切关注Linux内核的发展。他还是TECH VEDA公司的创始人,该公司以技术支持、内核贡献和高级培训的形式,专门从事与Linux内核有关的工程和技能服务。他对Linux有准确的理解和阐述,而且因为对软件设计和操作系统架构的狂热而得到了客户的特别关注。在向从事Linux内核、Linux驱动以及嵌入式Linux等工作的工程团队提供定制的且面向解决方案的培训计划这一方面,Raghu颇有心得。他所服务的客户有Xilinx(赛灵思)、通用、佳能、富士通、UTC(美国联合技术公司)、TCS(印度塔塔咨询服务公司)、博通、Sasken(印度萨斯肯通讯技术公司)、高通、Cognizant(高知特信息技术公司)、意法半导体、Stryker(史赛克)和Lattice(莱迪斯)半导体等公司。

首先,感谢Packt出版社给我这个机会来写作本书。向Packt出版社的所有编辑(Sharon及其团队)致以诚挚的问候,感谢他们的支持,他们为我按时、有序,且保质保量地写作本书提供了保障。

我还要感谢我的家人,感谢他们在我繁忙的写作期间给予的支持。

最后,我要特别感谢TECH VEDA公司的团队,他们不仅为我大开方便之门,而且还用他们各自的方式提供了宝贵的建议和反馈。

关于审稿人

Rami RosenLinux Kernel Networking:Implementation and Theory一书(Apress出版社于2013年出版)的作者。Rami已经在高科技公司工作了20多年,他的工作之路始于3家创业公司。他的大部分工作(无论是过去,还是现在)都是与内核和用户空间网络及虚拟化项目相关的——从设备驱动程序、内核网络栈、DPDK,到NFV和OpenStack,均有涉及。他偶尔会在国际会议上发表演讲,也时常为一家Linux新闻站点撰写文章。

感谢我的妻子Yoonhwa,在我利用周末时间审阅本书时,她给予了足够的宽容和支持。

前  言

本书讲解了Linux内核、内核的内部编排和设计,以及内核的各个核心子系统等知识,旨在帮助读者深入理解Linux内核。通过学习本书,你将会了解这个集众人之力而拥有了集体智慧的Linux内核,在保持其良好设计的同时,是如何保持其优雅特性的。

本书还将介绍所有关键的内核代码、核心数据结构、函数、宏,以便让读者全面、彻底地理解Linux内核的核心服务和机制。我们需要将Linux内核看作一个精心设计的软件,这可以让我们对软件设计的易扩展性、健壮性和安全性有整体且深入的了解。

本书内容

第1章,进程、地址空间和线程,详细讲解了Linux中名为“进程”的抽象概念以及整个生态系统,这有助于我们理解这一抽象概念。本章还介绍了地址空间、进程的创建和线程等内容。

第2章,进程调度器,讲解了进程调度的内容,这是任何操作系统的一个重要部分。本章将介绍Linux为了实现进程的有效执行而采取的不同调度策略。

第3章,信号管理,讲解了信号使用、信号表示、数据结构以及用于生成和传递信号的内核例程等信息。

第4章,内存管理和分配器,通过Linux内核中最关键的一个方面来介绍内存表示和分配的各种细微差异。本章还评估了内核在以最低成本来最大限度地使用资源方面的效率。

第5章,文件系统和文件I/O,对一个典型的文件系统的结构、设计,以及它能成为一个操作系统基本组成部分的原因进行了介绍。本章还介绍了使用通用分层架构设计的抽象,而内核通过VFS全面接纳了这种分层架构设计。

第6章,进程间通信,介绍了内核提供的各种IPC机制。本章将介绍每种IPC机制中各种数据结构之间的布局和关系,还有SysV和POSIX IPC机制。

第7章,虚拟内存管理,借助于虚拟内存管理和页表的细节介绍了内存管理相关的知识。本章将深入介绍虚拟内存子系统的各个方面,例如进程的虚拟地址空间和它的段、内存描述符结构、内存映射和VMA对象、页缓存和页表的地址转换。

第8章,内核同步和锁,介绍了内核提供的各种保护和同步机制以及这些机制的优缺点。本章还对内核如何解决这些变化同步的复杂性进行了介绍。

第9章,中断和延迟工作,介绍了中断相关的知识。中断是操作系统的关键部分,用来完成必要的和优先的任务。本章将介绍中断在Linux中是如何生成、处理和管理的。中断的各种下半部机制也会在本章进行讲解。

第10章,时钟和时间管理,介绍了内核度量和管理时间的方法。本章将介绍所有关键的与时间相关的结构体、例程和宏,以便我们能有效地衡量时间管理。

第11章,模块管理,简单介绍了模块、内核在管理模块中的基础结构,以及所涉及的所有核心数据结构等知识。这有助于我们理解内核是如何包含动态扩展性的。

阅读本书的前提条件

除了对Linux内核及其设计的细微差别具有强烈的好奇心,读者还需要对Linux操作系统有大致的了解,并使用开源软件的思想来学习本书。然而,这并不是阅读本书的必要条件,只要你想获取Linux系统及其工作机制的详细信息,就可以学习本书。

本书读者对象

希望能更深入地理解Linux内核及其各种组成部分的系统编程爱好者和专业人员。

开发各种内核相关项目的开发人员,本书是他们的随手读物。

软件工程专业的学生,他们可以将本书当作了解Linux内核的各个方面及其设计原理的参考指南。

资源与支持

本书由异步社区出品,社区(https://www.epubit.com/)为您提供相关资源和后续服务。

提交勘误

作者和编辑尽最大努力来确保书中内容的准确性,但难免会存在疏漏。欢迎您将发现的问题反馈给我们,帮助我们提升图书的质量。

当您发现错误时,请登录异步社区,按书名搜索,进入本书页面,点击“提交勘误”,输入勘误信息,点击“提交”按钮即可。本书的作者和编辑会对您提交的勘误进行审核,确认并接受后,您将获赠异步社区的100积分。积分可用于在异步社区兑换优惠券、样书或奖品。

扫码关注本书

扫描下方二维码,您将会在异步社区微信服务号中看到本书信息及相关的服务提示。

与我们联系

如果您对本书有任何疑问或建议,请您发邮件给我们,并请在邮件标题中注明本书书名,以便我们更高效地做出反馈。

如果您有兴趣出版图书、录制教学视频,或者参与图书翻译、技术审校等工作,可以发邮件给我们;有意出版图书的作者也可以向本书责任编辑投稿(邮箱为fudaokun@ptpress.com.cn)。

如果您来自学校、培训机构或企业,想批量购买本书或异步社区出版的其他图书,也可以发邮件给我们。

如果您在网上发现有针对异步社区出品图书的各种形式的盗版行为,包括对图书全部或部分内容的非授权传播,请您将怀疑有侵权行为的链接发邮件给我们。您的这一举动是对作者权益的保护,也是我们持续为您提供有价值的内容的动力之源。

关于异步社区和异步图书

“异步社区”是人民邮电出版社旗下IT专业图书社区,致力于出版精品IT图书和相关学习产品,为作译者提供优质出版服务。异步社区创办于2015年8月,提供大量精品IT图书和电子书,以及高品质技术文章和视频课程。更多详情请访问异步社区官网https://www.epubit.com。

“异步图书”是由异步社区编辑团队策划出版的精品IT专业图书的品牌,依托于人民邮电出版社近30年的计算机图书出版积累和专业编辑团队,相关图书在封面上印有异步图书的LOGO。异步图书的出版领域包括软件开发、大数据、AI、测试、前端、网络技术等。

异步社区

微信服务号

第1章 进程、地址空间和线程

在当前进程上下文中调用内核服务时,通过研究进程上下文的设计布局可更详细地探索内核。本章主要介绍进程,以及内核为进程提供的底层生态系统。本章将介绍以下概念:

程序的处理;

进程的布局;

虚拟地址空间;

内核和用户空间;

进程API;

进程描述符;

内核堆栈管理;

线程;

Linux线程API;

数据结构;

命名空间和cgroup。

1.1 进程

从本质上讲,计算系统是为了高效运行用户应用程序而设计、开发和经常微调的。而进入计算平台的每个元素都旨在为运行应用程序提供有效且高效的方法。换句话说,计算系统的存在就是为了运行各种不同的应用程序。应用程序既可以作为专用设备中的固件来运行,也可以作为由系统软件(操作系统)驱动的系统中的一个“进程”来运行。

进程的核心是程序在内存中的一个运行实例。从程序到进程的转换过程发生在程序(在磁盘上)被读取到内存中执行时。

一个程序的二进制映像包含代码(所有的二进制指令)和数据(所有的全局数据),它们被映射到内存的不同区域,并具有适当的访问权限(读、写和执行)。除了代码和数据之外,进程还被分配了额外的内存区域,称为堆栈(用于分配具有自动变量和函数参数的函数调用帧),以及在运行时动态分配的堆。

同一个程序的多个实例可以在它们各自的内存分配中存在。例如,对于具有多个打开的选项卡(同时运行浏览器会话)的Web浏览器,每个选项卡都被内核视为一个进程实例,各自具有唯一的内存分配。

图1-1所示为内存中进程的布局。

图1-1

1.1.1 所谓地址空间的错觉

现代计算平台有望有效地处理大量的进程。因此,操作系统必须处理在物理内存(通常是有限的)中为所有并发的进程分配唯一的内存,并确保其可靠的执行。由于多个进程同时发生并执行(多任务),操作系统必须确保每个进程的内存分配都得到保护,以免被另一进程意外访问。

为了解决这个问题,内核在进程和物理内存之间提供了一层抽象,称为虚拟地址空间。虚拟地址空间是进程的内存视图。那么,运行中的程序是如何看待内存的呢?

虚拟地址空间创建了一个假象,即每个进程在执行过程中独占整个内存。这种抽象的内存视图称为虚拟内存,它是由内核的内存管理器与CPU的MMU协调实现的。每个进程都有一个连续的32位或64位地址空间,这个地址空间被体系结构所限定,并且对于该进程是唯一的。通过MMU,每个进程装入其虚拟地址空间中,任何进程尝试访问其边界之外的地址区域都会触发硬件故障,从而使内存管理器能够检测和终止违反的进程,这样就确保进程得到了保护。

图1-2所示为每个不同进程创建地址空间的假象。

图1-2

1.1.2 内核空间和用户空间

现代操作系统不仅可以防止一个进程访问另一个进程,还可以防止进程意外访问或操作内核数据和服务(因为内核地址空间是被所有进程共享的)。

操作系统实现这种保护,是通过将整个内存分割成两个逻辑分区:用户空间和内核空间。这种分开设计确保所有分配有地址空间的进程都映射到内存的用户空间部分,而内核数据和服务在内核空间中运行。内核通过与硬件协调配合实现了这种保护。当应用程序进程正在执行代码段中的指令时,CPU在用户模式下运行。当一个进程打算调用一个内核服务时,它需要将CPU切换成特权模式(内核模式),这是通过称为API(应用程序编程接口)的特殊函数来实现的。这些API允许用户进程使用特殊的CPU指令切换到内核空间,然后通过系统调用来执行所需要的服务。在所请求的服务完成后,内核使用另一组CPU指令来执行到另一个模式的切换,这次是从内核模式返回到用户模式。

注意

系统调用是内核将其服务公开到应用程序进程的接口,它们也被称为内核的入口点。由于系统调用是在内核空间中实现的,对应的处理程序通过用户空间中的API提供。API抽象层也使得调用相关的系统调用变得更容易和方便。

图1-3所示为一幅虚拟的内存视图。

图1-3

进程上下文

当一个进程通过系统调用请求一个内核服务时,内核将代表调用进程来执行。此时,内核就被认为是在进程上下文中执行的。类似地,内核也会响应其他硬件实体引发的中断;而这里就是说内核在中断上下文中执行。在中断上下文中,内核不代表任何进程来运行。

1.2 进程描述符

从一个进程诞生到退出的时间里,内核的进程管理子系统执行了各种操作,从进程创建、分配CPU时间、事件通知到进程终止时销毁进程。

除了地址空间之外,一个进程在内存中还被分配了一个称为进程描述符的数据结构,内核用它来识别、管理和调度该进程。图1-4描述了内核中的进程地址空间及其进程描述符。

图1-4

在Linux中,一个进程描述符是<linux/sched.h>中定义的struct task_struct类型的一个实例,它是核心数据结构之一,包含一个进程所拥有的所有属性、标识的详细信息和资源分配条目。查看struct task_struct就像是窥探内核在管理和调度进程时所看到或所使用的内容。

由于任务结构体包含一系列广泛的数据元素,这些元素与不同的内核子系统的功能相关,因此在本章中我们将单独探讨所有元素的目的和范围。我们将介绍一些与进程管理相关的重要元素。

1.2.1 进程属性:关键元素

进程属性定义了一个进程的所有关键特征和基本特征。这些元素包含进程的状态和标识以及其他重要的键值。

1.状态

一个进程从其产生之时起直至退出就一直处于不同的状态中,称为进程状态——它们定义了进程的当前状态。

TASK_RUNNING (0):任务正在执行或在调度器运行队列中争抢CPU。

TASK_INTERRUPTIBLE (1):任务处于可中断的等待状态;它仍然处于等待状态,直到所等待的条件变为真,例如互斥锁可用、I/O准备好的设备、睡眠时间超时,或者是一个专属的唤醒调用。在这个等待状态中,为进程生成的任何信号都被传递,使得等待条件被满足前唤醒进程。

TASK_KILLABLE:这与TASK_INTERRUPTIBLE类似,不同之处在于中断只能发生在致命信号上,这使它成为TASK_INTERRUPTIBLE以外更好的选择。

TASK_UNINTERRUPTIBLE (2):任务处于不可中断的等待状态,类似于TASK_ INTERRUPTIBLE,但是产生信号给这种睡眠进程不会导致其被唤醒。当它正在等待的事件发生时,进程才转换为TASK_RUNNING状态。该进程状态很少使用。

TASK_STOPPED (4):该任务已经收到停止(STOP)信号。在接收到继续信号(SIGCONT)后,它会回到运行状态。

TASK_TRACED (8):当一个进程可能正在被一个调试器仔细检查,便可认为它处于跟踪状态。

EXIT_ZOMBIE (32):该进程已经被终止,但它的资源尚未回收。

EXIT_DEAD (16):父进程使用wait方法收集子进程的退出状态后,子进程将终止,并且释放它所持有的所有资源。

图1-5描述了进程状态。

图1-5

2.pid

该字段保存了进程唯一的标识符,称为 PID。Linux中的PID是pid_t(整数)类型。虽然PID是一个整数,但通过/proc/sys/kernel/pid_max接口指定的默认最大值只有32 768。该文件中的值可以设置为任何值,最高可达222(PID_MAX_LIMIT,约为400万)。

为了管理 PID,内核使用了位图。该位图允许内核跟踪PID的使用情况,并且可以为新进程分配唯一的PID。每个PID都是由PID位图中的一个位来标识的;PID的值是根据其对应位的位置来确定的。在位图中,值为1的位表示正在使用相应的PID,值为0的位表示空闲的PID。每当内核需要分配一个唯一的PID时,它就会查找第一个未被设置的位并将其设置为1,相反地,释放一个PID时,它会将相应的位从1设置为0。

3.tgid

该字段保存了线程组id。为了便于理解,假设创建了一个新进程,它的PID和TGID是相同的,因为进程恰好是唯一的线程。当进程产生一个新的线程时,新的子进程将获得唯一的PID,但是继承了父线程的TGID,因为它属于同一个线程组。TGID主要用于支持多线程进程。我们将在本章后面的线程部分深入了解。

4.thread info

该字段保存了处理器特定的状态信息,并且它是任务结构体的关键元素。本章后文会包含有关thread_info的重要细节。

5.flags

该标志字段记录了进程相应的各种属性。该字段中的每一位对应于一个进程生命周期中的各个阶段。每个进程标志定义在<linux/sched.h>中。

#define PF_EXITING           /* getting shut down */
#define PF_EXITPIDONE        /* pi exit done on shut down */
#define PF_VCPU              /* I'm a virtual CPU */
#define PF_WQ_WORKER         /* I'm a workqueue worker */
#define PF_FORKNOEXEC        /* forked but didn't exec */
#define PF_MCE_PROCESS       /* process policy on mce errors */
#define PF_SUPERPRIV         /* used super-user privileges */
#define PF_DUMPCORE          /* dumped core */
#define PF_SIGNALED          /* killed by a signal */
#define PF_MEMALLOC          /* Allocating memory */
#define PF_NPROC_EXCEEDED    /* set_user noticed that RLIMIT_NPROC was exceeded */
#define PF_USED_MATH         /* if unset the fpu must be initialized before use */
#define PF_USED_ASYNC       /* used async_schedule*(), used by module init */
#define PF_NOFREEZE         /* this thread should not be frozen */
#define PF_FROZEN           /* frozen for system suspend */
#define PF_FSTRANS          /* inside a filesystem transaction */
#define PF_KSWAPD           /* I am kswapd */
#define PF_MEMALLOC_NOIO0   /* Allocating memory without IO involved */
#define PF_LESS_THROTTLE    /* Throttle me less: I clean memory */
#define PF_KTHREAD          /* I am a kernel thread */
#define PF_RANDOMIZE        /* randomize virtual address space */
#define PF_SWAPWRITE        /* Allowed to write to swap */
#define PF_NO_SETAFFINITY   /* Userland is not allowed to meddle with cpus_allowed */
#define PF_MCE_EARLY        /* Early kill for mce process policy */
#define PF_MUTEX_TESTER     /* Thread belongs to the rt mutex tester */
#define PF_FREEZER_SKIP     /* Freezer should not count it as freezable */
#define PF_SUSPEND_TASK     /* this thread called freeze_processes and should not be 
frozen */

6.exit_code和exit_signal

这些字段保存了任务的退出值和导致终止的信号的详细信息。这些字段将由父进程在子进程终止时通过wait()访问。

7.comm

该字段保存了用于启动进程的二进制可执行文件的名称。

8.ptrace

当使用ptrace()系统调用使进程转为跟踪模式时,将启用并设置该字段。

1.2.2 进程关系:关键元素

每个进程都可以与父进程关联,并建立父子关系。同样,由同一进程产生的多个进程被称为兄弟进程。这些字段确定当前进程与另一个进程的关系。

1.real_parent和parent

这些是指向父任务结构体的指针。对于正常的进程,这两个指针都指向同一个task_struct。它们的区别仅在于使用posix线程实现的多线程进程。对于这种情况,real_parent指向父线程任务结构体,parent指向收到SIGCHLD信号的进程任务结构体。

2.children

这是指向子任务结构体链表的指针。

3.sibling

这是一个指向兄弟任务结构体链表的指针。

4.group_leader

这个指针指向进程组组长的任务结构体。

1.2.3 调度属性:关键元素

所有相互竞争的进程都必须拥有公平的CPU时间,这就要求基于时间片和进程优先级来调度。以下这些属性包含了调度器所需的必要信息,以帮助确定哪个进程在竞争时获得优先权。

1.prio和static_prio

prio帮助确定调度进程的优先级。如果进程被分配了实时调度策略,则此字段保存了进程的静态优先级,范围为1~99(由sched_setscheduler()指定)。对于正常的进程,这个字段保存了由nice值得来的动态优先级。

2.se、rt和dl

每个任务都属于调度实体(任务组),因为调度是在每个实体级别上完成的。se用于所有正常进程,rt用于实时进程,dl用于截止期进程。我们将在下一章讨论关于调度的这些属性的更多细节。

3.policy

该字段保存了和进程调度策略相关的信息,这有助于确定进程的优先级。

4.cpus_allowed

该字段指定了进程的CPU掩码。也就是说,在多处理器系统中,进程允许在哪个CPU上进行调度。

5.rt_priority

该字段用于指定实时调度策略的进程优先级。但对于非实时进程,该字段未被使用。

1.2.4 进程限制:关键元素

内核施加资源限制以确保在相互竞争的进程中公平分配系统资源。这些限制保证了任意一个进程都不会独占所有的资源。有16种不同类型的资源限制,task structure指向一个struct rlimit类型的数组,其中每个偏移量包含了一个特定资源的当前值和最大值。

/*include/uapi/linux/resource.h*/
struct rlimit {
  __kernel_ulong_t        rlim_cur;
  __kernel_ulong_t        rlim_max;
};

这些限制在include/uapi/asm-generic/resource.h中进行了指定。

#define RLIMIT_CPU        0        /* CPU time in sec */
#define RLIMIT_FSIZE      1        /* Maximum filesize */
#define RLIMIT_DATA       2        /* max data size */
#define RLIMIT_STACK      3        /* max stack size */
#define RLIMIT_CORE       4        /* max core file size */
#ifndef RLIMIT_RSS
# define RLIMIT_RSS       5        /* max resident set size */
#endif
#ifndef RLIMIT_NPROC
# define RLIMIT_NPROC     6        /* max number of processes */
#endif
#ifndef RLIMIT_NOFILE
# define RLIMIT_NOFILE    7        /* max number of open files */
#endif
#ifndef RLIMIT_MEMLOCK
# define RLIMIT_MEMLOCK   8        /* max locked-in-memory
address space */
#endif
#ifndef RLIMIT_AS
# define RLIMIT_AS        9        /* address space limit */
#endif
#define RLIMIT_LOCKS      10       /* maximum file locks held */
#define RLIMIT_SIGPENDING 11       /* max number of pending signals */
#define RLIMIT_MSGQUEUE   12       /* maximum bytes in POSIX mqueues */
#define RLIMIT_NICE       13       /* max nice prio allowed to
raise to 0-39 for nice level  19 .. -20 */
#define RLIMIT_RTPRIO     14       /* maximum realtime priority */
#define RLIMIT_RTTIME     15       /* timeout for RT tasks in us */
#define RLIM_NLIMITS      16

1.2.5 文件描述符表:关键元素

在进程的生命周期中,它可以访问各种资源文件来完成其任务。这会导致进程打开、关闭、读取和写入这些文件。而系统又必须跟踪这些行为;文件描述符元素可以帮助系统了解进程操作了哪些文件。

1.fs

文件系统信息存储在该字段中。

2.files

文件描述符表保存了一些指针,这些指针指向进程为了执行各种操作而打开的所有文件。而files字段保存了一个指向该文件描述符表的指针。

1.2.6 信号描述符:关键元素

对于要处理信号的进程,任务结构体中有各种元素,而这些元素决定着信号必须如何处理。

1.signal

这是struct signal_struct类型的元素,它保存了与进程相关的所有信号的信息。

2.sighand

这是struct sighand_struct类型的元素,它保存了与进程相关的所有信号的处理函数。

3.sigset_t blocked和real_blocked

这些元素标识了当前被进程屏蔽或阻塞的信号。

4.pending

这是struct sigpending 类型的,它用来标识已经生成但尚未传递的信号。

5.sas_ss_sp

该字段保存了一个指向备用堆栈的指针,它有助于信号处理。

6.sas_ss_size

该字段表示用于信号处理的备用堆栈的大小。

1.3 内核栈

在基于多核硬件的当代计算平台上,可以同时并行地运行应用程序。因此,在请求同一个进程时,可以同时启动多个进程的内核模式切换。为了能够处理这种情况,内核服务被设计为可重入的,允许多个进程介入并使用所需的服务。这就要求请求进程维护它自己的私有内核栈,来跟踪内核函数调用顺序,存储内核函数的本地数据,等等。

内核栈直接映射到物理内存,强制排列在物理上处于连续的区域中。默认情况下,内核栈对于x86-32和大多数其他32位系统(在内核构建期间可以配置4KB内核栈的选项)为8KB,在x86-64系统上为16KB。

当内核服务在当前进程上下文中被调用时,它们需要在进行任何相关操作之前验证进程的特权。要执行这类验证,内核服务必须能够访问当前进程的任务结构体并查看相关字段。同样,内核例程可能需要访问当前task structure,以修改各种资源结构体(如信号处理程序表),查找被挂起的信号、文件描述符表和内存描述符等。为了能够在运行时访问task structure,内核将当前task structure的地址加载到处理器寄存器(所选的寄存器与体系结构相关)中,并通过称为current的内核全局宏提供访问(在体系结构特定的内核头文件asm/current.h中定义):

/* arch/ia64/include/asm/current.h */
#ifndef _ASM_IA64_CURRENT_H
#define _ASM_IA64_CURRENT_H
/*
* Modified 1998-2000
*      David Mosberger-Tang <davidm@hpl.hp.com>, Hewlett-Packard Co
*/
#include <asm/intrinsics.h>
/*
* In kernel mode, thread pointer (r13) is used to point to the
  current task
* structure.
*/
#define current ((struct task_struct *) ia64_getreg(_IA64_REG_TP))
#endif /* _ASM_IA64_CURRENT_H */
/* arch/powerpc/include/asm/current.h */
#ifndef _ASM_POWERPC_CURRENT_H
#define _ASM_POWERPC_CURRENT_H
#ifdef __KERNEL__
/*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version
* 2 of the License, or (at your option) any later version.
*/
struct task_struct;
#ifdef __powerpc64__
#include <linux/stddef.h>
#include <asm/paca.h>
static inline struct task_struct *get_current(void)
{
      struct task_struct *task;
 
      __asm__ __volatile__("ld %0,%1(13)"
      : "=r" (task)
      : "i" (offsetof(struct paca_struct, __current)));
      return task;
}
#define current get_current()
#else
/*
* We keep `current' in r2 for speed.
*/
register struct task_struct *current asm ("r2");
#endif
#endif /* __KERNEL__ */
#endif /* _ASM_POWERPC_CURRENT_H */

然而,在寄存器受限的体系结构中,只有很少的寄存器可用,预留一个寄存器来保存当前任务结构体的地址是不可行的。在这样的平台上,当前进程的task structure可以直接在其拥有的内核栈的顶部使用。通过屏蔽栈指针的最低有效位,这种方法在确定task structure位置方面具有很大的优势。

随着内核的演变,task structure增长并变得太大而无法容纳在内核栈中,而内核栈已经被限制在物理内存中(8KB)。因此,除了一些关键字段(如定义进程的CPU状态和其他底层处理器相关的信息),task structure已经被移出内核栈。然后将这些字段封装在一个新创建的结构体中,称为struct thread_info。这个结构体包含在内核栈的顶部,并提供一个指向当前task structure的指针,该指针可以被内核服务所使用。

下面的代码片段展示了x86体系结构(内核3.10)的struct thread_info:

/* linux-3.10/arch/x86/include/asm/thread_info.h */
struct thread_info {
 struct task_struct *task; /* main task structure */
 struct exec_domain *exec_domain; /* execution domain */
 __u32 flags; /* low level flags */
 __u32 status; /* thread synchronous flags */
 __u32 cpu; /* current CPU */
 int preempt_count; /* 0 => preemptable, <0 => BUG */
 mm_segment_t addr_limit;
 struct restart_block restart_block;
 void __user *sysenter_return;
 #ifdef CONFIG_X86_32
 unsigned long previous_esp; /* ESP of the previous stack in case of
 nested (IRQ) stacks */
 __u8 supervisor_stack[0];
 #endif
 unsigned int sig_on_uaccess_error:1;
 unsigned int uaccess_err:1; /* uaccess failed */
};

使用包含了进程相关信息的thread_info,除task structure之外,内核对当前进程结构体有多个视角:一个与体系结构无关的信息块struct task_struct和一个与体系结构相关的thread_info。图1-6描述了thread_info和task_struct。

图1-6

对于使用thread_info的体系结构,内核修改了当前宏的实现,以查看内核栈的顶部,从而获取对当前thread_info的引用,并通过它来获得当前的task structure。下面的代码片段所示为当前x86-64平台的实现。

#ifndef __ASM_GENERIC_CURRENT_H
#define __ASM_GENERIC_CURRENT_H
#include <linux/thread_info.h>
#define get_current() (current_thread_info()->task)
#define current get_current()
#endif /* __ASM_GENERIC_CURRENT_H */
/*
* how to get the current stack pointer in C
*/
register unsigned long current_stack_pointer asm ("sp");
/*
 * how to get the thread information struct from C
 */
static inline struct thread_info *current_thread_info(void)
__attribute_const__;
static inline struct thread_info *current_thread_info(void)
{
       return (struct thread_info *)
               (current_stack_pointer & ~(THREAD_SIZE - 1));
}

随着近段时间越来越多地使用PER_CPU变量,进程调度器进行了优化,它在PER_CPU区域中缓存了当前与进程相关的关键信息。这一更改使得可以通过查找内核栈来快速访问当前进程数据。下面的代码片段所示为current宏通过PER_CPU变量获取当前任务数据的实现。

#ifndef _ASM_X86_CURRENT_H
#define _ASM_X86_CURRENT_H
#include <linux/compiler.h>
#include <asm/percpu.h>
#ifndef __ASSEMBLY__
struct task_struct;
DECLARE_PER_CPU(struct task_struct *, current_task);
static __always_inline struct task_struct *get_current(void)
{
        return this_cpu_read_stable(current_task);
}
 
#define current get_current()
#endif /* __ASSEMBLY__ */
 
#endif /* _ASM_X86_CURRENT_H */

使用PER_CPU数据会导致thread_info中的信息逐渐减少。随着thread_info规模的缩小,内核开发者正在考虑通过将thread_info移动到task structure中,从而完全清除thread_info。由于这涉及对底层体系结构代码的修改,目前只在x86-64体系结构中实现了,而其他体系结构也计划跟随这项改动。以下代码片段所示为只有一个元素的thread_info结构体的当前状态。

/* linux-4.9.10/arch/x86/include/asm/thread_info.h */
struct thread_info {
 unsigned long flags; /* low level flags */
};

1.4 栈溢出问题

与用户模式不同,内核模式栈位于直接映射的内存中。当一个进程调用一个可能在内部被深度嵌套的内核服务时,它有可能会超出当前的内存运行范围。最糟糕的是,内核会察觉不到这种情况。内核程序员通常会使用各种调试选项来跟踪栈使用情况并检测溢出,而这些方法都不便于在生产系统上防止栈溢出。这里也排除了通过使用保护页面的传统保护方式(因为它浪费了一个实际的内存页面)来避免内核栈溢出问题。

内核开发者倾向于遵循编码标准——尽量减少使用本地数据、避免递归、避免深度嵌套等,以减少栈被破坏的可能性。但是,实现功能丰富和深度分层的内核子系统可能会带来各种设计挑战和复杂性,尤其是对于文件系统、存储驱动程序和网络代码可以堆叠在多个层次中的存储子系统,这会导致深度嵌套的函数调用。

在相当长的时间里,Linux内核社区一直在思考如何预防这类栈破坏问题,为此,决定将内核栈的大小扩展到16KB(x86-64,自内核3.15开始)。内核栈的扩展可能会阻止一部分破坏,但代价是为每个进程内核栈占用许多直接映射的内核内存。但是,为了系统的可靠运行,内核期望在生产系统上出现栈破坏时能够优雅地处理它们。

随着4.9版本的发布,内核已经有了一个新的系统来建立虚拟映射的内核栈。由于当前正在使用虚拟地址来映射甚至直接映射页,内核栈实际上并不需要物理上连续的页。内核为虚拟映射的内存预留了一个单独的地址范围,并且在调用vmalloc()时分配此范围内的地址。这个内存范围称为vmalloc范围。当程序需要分配大量内存时使用这个范围,这些内存实际上是虚拟连续的,但物理上是分散的。使用这个方法,内核栈现在可以分配为单独的页,映射到vmalloc范围。虚拟映射还可以防止溢出,因为它可以使用页表项分配一个不可访问的保护页(而不浪费实际页)。保护页会提示内核在内存溢出时弹出oops消息,并杀死溢出进程。

具有保护页的虚拟映射内核栈目前仅适用于x86-64体系结构(对于其他体系结构的支持,看似也会跟进)。这可以通过选择HAVE_ARCH_VMAP_STACK或CONFIG_VMAP_ STACK的构建时选项来开启该功能。

1.5 进程创建

在内核启动期间,会创建一个名为init的内核线程,该内核线程接着又被配置为初始化第一个用户模式进程(具有相同的名称)。然后init(pid 1)进程执行通过配置文件指定的各种初始化操作,创建一系列进程。每个进一步创建的子进程(可能会接着创建自己的子进程)都是init进程的后代。因此,创建的进程最终形成了类似于树状结构或单一层次的模型。Shell就是这样的一个进程,当程序被调用执行时,它成为用户创建用户进程的接口。

fork、vfork、exec、clone、wait和exit是创建和控制新进程的核心内核接口。这些操作是通过相应的用户模式API调用的。

1.5.1 fork()

自从传统的UNIX版本发布以来,fork()就是*nix系统中可用的核心“UNIX线程API”之一。正如其名字一样,它从正在运行的进程中分出一个新进程。当fork()执行成功时,通过复制调用者的地址空间和任务结构体来创建新进程(称为子进程)。从fork()返回时,调用者(父)和新进程(子)会继续执行来自同一代码段的指令,该指令是通过写时复制的方式复制而来的。fork()可能是唯一一个在调用者进程上下文中进入内核模式的API,并且在执行成功后,会在调用者和子进程(新进程)的上下文中返回到用户模式。

除了少数一些属性,如内存锁、挂起的信号、活跃的定时器和文件记录锁(有关例外的完整列表,请参阅fork(2)帮助文档)之外,父进程task structure的大多数资源条目(如内存描述符、文件描述符表、信号描述符和调度属性)都由子进程继承。子进程被赋予一个唯一的pid,并通过其task structure的ppid字段引用其父进程的pid;而子进程的资源利用和处理器使用条目会被重置为零。

父进程可以通过使用 wait()系统调用更新自己关于子进程的状态,并且通常等待子进程的终止。假如未能调用wait(),子进程可能会终止并且进入僵尸状态。

1.5.2 写时复制(COW)

在通过复制父进程来创建子进程时,需要为子进程克隆父进程的用户模式地址空间(栈、数据、代码和堆段)和任务结构体;而这会导致执行开销,从而导致创建进程时间的不确定性。更糟糕的是,如果父进程和子进程都没有对克隆资源进行任何状态更改操作,这个克隆过程将变得毫无用处。

根据写时复制(Copy-On-Write,COW),当创建一个子进程时,会为其分配一个唯一的task structure,其中包含引用父进程task structure的所有资源条目(包括页表),并且对父进程和子进程有只读访问权限。当两个进程中的任意一个启动状态更改操作时,资源才会被真正复制,因此称为写时复制。COW中的Write就意味着状态更改。COW通过将复制进程数据的需求延迟到直到写入时才完成,并且在只发生读取的时候完全避免复制,使效率和优化凸显出来。这种按需复制还可以减少所需交换页的数量,缩短花费在交换页上的时间,并有助于减少分页请求。

1.5.3 exec

有时候,创建一个子进程可能用处不大,除非它完全运行一个新的程序,exec系列函数正是为此目的而服务的。exec通过在现有的进程中执行一个新的可执行二进制文件来替代现有的程序:

#include <unistd.h>
int execve(const char *filename, char *const argv[],
char *const envp[]);

execve是一个系统调用,它会将第一个传给它的参数作为路径,用来执行二进制文件程序。第二个和第三个参数是以null结尾的数组参数和字符串环境变量,它们将作为命令行参数传递给一个新程序。这个系统调用也可以通过各种glibc(库)封装器来调用,这样会更加方便和灵活:

include <unistd.h>
extern char **environ;
int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg,
..., char * const envp[]);
int execv(const char *path, char *constargv[]);
int execvp(const char *file, char *constargv[]);
int execvpe(const char *file, char *const argv[],
char *const envp[]);

命令行用户界面程序(如shell)使用exec接口来启动用户请求的程序二进制文件。

1.5.4 vfork()

与fork()不同,vfork()创建子进程并会阻塞父进程,这意味着子进程会作为一个单独的线程运行并且不允许与父进程并发;换句话说,父进程暂时被挂起,直到子进程退出或调用exec()。子进程共享父进程的数据。

1.5.5 Linux线程支持

一个进程中的执行流被称为线程(thread),这意味着每个进程至少会有一个执行线程。多线程意味着在一个进程中存在多个执行上下文流。使用现代的多核体系结构,一个进程中的多个执行流可以真正并发,实现公平的多任务处理。

在计划执行的进程中,线程通常被枚举为纯用户级实体;它们共享父进程的虚拟地址空间和系统资源。每个线程维护其自身代码、堆栈和线程本地存储。线程由线程库调度和管理,线程库使用称为线程对象的结构体来保存唯一的线程标识符,用于调度属性和保存线程上下文。用户级线程应用程序在内存上通常比较轻量化,并且是事件驱动型应用程序的首选并发性模型。另一方面,这样的用户级线程模型不适合并行计算,因为它们被绑定在与父进程绑定的同一个处理器核上执行。

Linux不直接支持用户级线程,它提出了一个替代API枚举并称为轻量级进程(Light Weight Process,LWP)的特殊进程,该进程可以与父进程共享一组配置资源,例如动态内存分配、全局数据、打开文件、信号处理程序和其他广泛的资源。每个LWP由一个唯一的PID和任务结构体来标识,并被内核视为一个独立的执行上下文。在Linux中,术语“线程”总是指LWP,因为由线程库(Pthreads)初始化的每个线程都被内核枚举为LWP。

clone()

clone()是Linux特有的一个系统调用,用来创建一个新的进程;它被认为是fork()系统调用的通用版本,通过flags参数提供更精细的控制来自定义其功能:

int clone(int (*child_func)(void *), void *child_stack, int flags, void *arg);

它提供了超过20种不同的CLONE_*标志来控制 clone操作的各个方面,包括父进程和子进程是否共享资源,如虚拟内存、打开文件描述符和信号处理。使用适当的内存地址(作为第二个参数传递)创建子进程,以用作堆栈(用于存储子进程的本地数据)使用。子进程以其启动函数(作为第一个参数传递给clone调用)开始执行。

当进程尝试通过pthread库创建线程时,会使用以下标志(见表1-1)调用clone():

/*clone flags for creating threads*/
flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|
CLONE_SETTLS|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID;

表1-1

标志

含义

CLONE_VM

启用父进程虚拟地址空间(包含活跃的内存映射)的共享(读/写)

CLONE_FS

启用父进程文件系统信息的共享(当前工作目录umask)

CLONE_FILES

启用同一个文件描述符表的共享。由调用进程或者子进程创建的任何文件描述符在其他进程中也是有效的

CLONE_SIGHAND

让父进程和子进程共享信号处理程序列表,注意该选项不会影响信号掩码以及挂起的信号列表

CLONE_THREAD

每一个LWP都有自己的PID。线程库标准强制多线程应用程序中的所有线程都能绑定到同一个PID。为此,Linux使用了线程组的概念,线程组具有不同的组ID。当设置了该标志时,子线程将与父进程放到同一个线程组中

CLONE_SYSVSEM

让子线程与调用进程共享System V信号量调整值的单个列表

CLONE_SETTLS

为子线程创建新的线程本地存储描述符

CLONE_PARENT_ SETTID

通过fork()系统生成的子进程有时可能在其PID返回给父进程上下文之前退出。当发生这种情况时,父进程将不再跟踪子进程的状态。可以通过pthread库来启用该标志,以便在子进程开始执行之前将子TID存储在父进程内存中的ptid位置

CLONE_CHILD_ CLEARTID

当线程退出时,必须释放掉它的栈。这个标志允许在父进程等待futex的内存位置清除子TID,直到唤醒后的父进程释放线程栈

clone()也可以用来创建一个常规子进程,但通常是使用fork()和vfork()生成的:

/* clone flags for forking child */
flags = SIGCHLD;
/* clone flags for vfork child */
flags = CLONE_VFORK | CLONE_VM | SIGCHLD;

1.6 内核线程

为了满足运行后台操作的需要,内核会创建线程(类似于进程)。这些内核线程与常规进程相似,因为它们也是由任务结构体表示的并且分配了一个PID。与用户进程不同的是,它们没有映射任何地址空间,并且只在内核模式下运行,这使得它们不具有交互性。各种内核子系统使用kthreads线程来周期性运行和进行异步操作。

所有的内核线程都是kthreadd(pid 2)的后代,它是在引导期间由kernel(pid 0)创建的。kthreadd枚举了其他内核线程;它提供了接口例程,通过它可以由内核服务在运行时动态地产生其他内核线程。可以使用ps -ef命令从命令行查看内核线程——它们显示在方括号中:

UID PID PPID C STIME TTY TIME CMD
root 1 0 0 22:43 ? 00:00:01 /sbin/init splash
root 2 0 0 22:43 ? 00:00:00 [kthreadd]
root 3 2 0 22:43 ? 00:00:00 [ksoftirqd/0]
root 4 2 0 22:43 ? 00:00:00 [kworker/0:0]
root 5 2 0 22:43 ? 00:00:00 [kworker/0:0H]
root 7 2 0 22:43 ? 00:00:01 [rcu_sched]
root 8 2 0 22:43 ? 00:00:00 [rcu_bh]
root 9 2 0 22:43 ? 00:00:00 [migration/0]
root 10 2 0 22:43 ? 00:00:00 [watchdog/0]
root 11 2 0 22:43 ? 00:00:00 [watchdog/1]
root 12 2 0 22:43 ? 00:00:00 [migration/1]
root 13 2 0 22:43 ? 00:00:00 [ksoftirqd/1]
root 15 2 0 22:43 ? 00:00:00 [kworker/1:0H]
root 16 2 0 22:43 ? 00:00:00 [watchdog/2]
root 17 2 0 22:43 ? 00:00:00 [migration/2]
root 18 2 0 22:43 ? 00:00:00 [ksoftirqd/2]
root 20 2 0 22:43 ? 00:00:00 [kworker/2:0H]
root 21 2 0 22:43 ? 00:00:00 [watchdog/3]
root 22 2 0 22:43 ? 00:00:00 [migration/3]
root 23 2 0 22:43 ? 00:00:00 [ksoftirqd/3]
root 25 2 0 22:43 ? 00:00:00 [kworker/3:0H]
root 26 2 0 22:43 ? 00:00:00 [kdevtmpfs]
/*kthreadd creation code (init/main.c) */
static noinline void __ref rest_init(void)
{
 int pid;
 
 rcu_scheduler_starting();
 /*
 * We need to spawn init first so that it obtains pid 1, however
 * the init task will end up wanting to create kthreads, which, if
 * we schedule it before we create kthreadd, will OOPS.
 */
 kernel_thread(kernel_init, NULL, CLONE_FS);
 numa_default_policy();
 pid = kernel_thread(kthreadd, NULL, CLONE_FS | CLONE_FILES);
 rcu_read_lock();
 kthreadd_task = find_task_by_pid_ns(pid, &init_pid_ns);
 rcu_read_unlock();
 complete(&kthreadd_done);
 
 /*
 * The boot idle thread must execute schedule()
 * at least once to get things moving:
 */
 init_idle_bootup_task(current);
 schedule_preempt_disabled();
 /* Call into cpu_idle with preempt disabled */
 cpu_startup_entry(CPUHP_ONLINE);
}

上述代码展示了内核引导例程rest_init()用适当的参数调用kernel_thread()例程,用来创建kernel_init(然后继续启动用户模式init进程)和kthreadd线程。

kthread是一个永久运行的线程,它会查看名为kthread_create_list的链表,以获取有关要创建的新kthreads线程的数据:

/*kthreadd routine(kthread.c) */
int kthreadd(void *unused)
{
 struct task_struct *tsk = current;
 
 /* Setup a clean context for our children to inherit. */
 set_task_comm(tsk, "kthreadd");
 ignore_signals(tsk);
 set_cpus_allowed_ptr(tsk, cpu_all_mask);
 set_mems_allowed(node_states[N_MEMORY]);
 
 current->flags |= PF_NOFREEZE;
 
 for (;;) {
 set_current_state(TASK_INTERRUPTIBLE);
 if (list_empty(&kthread_create_list))
 schedule();
 __set_current_state(TASK_RUNNING);
 spin_lock(&kthread_create_lock);
 while (!list_empty(&kthread_create_list)) {
 struct kthread_create_info *create;
 
 create = list_entry(kthread_create_list.next,
 struct kthread_create_info, list);
 list_del_init(&create->list);
 spin_unlock(&kthread_create_lock);
 create_kthread(create); /* creates kernel threads with attributes enqueued */
 
 spin_lock(&kthread_create_lock);
 }
 spin_unlock(&kthread_create_lock);
 }
 
 return 0;
}

内核线程通过调用kthread_create或通过其封装的kthread_run函数传递合适的参数来创建,这些参数定义了kthreadd(启动例程、ARG数据和名称)。以下代码片段展示了kthread_ create调用kthread_create_on_node(),默认情况下,它在当前的Numa节点上创建线程:

struct task_struct *kthread_create_on_node(int (*threadfn)(void *data),
 void *data,
 int node,
 const char namefmt[], ...);
 
/**
 * kthread_create - create a kthread on the current node
 * @threadfn: the function to run in the thread
 * @data: data pointer for @threadfn()
 * @namefmt: printf-style format string for the thread name
 * @...: arguments for @namefmt.
 *
 * This macro will create a kthread on the current node, leaving it in
 * the stopped state. This is just a helper for
 * kthread_create_on_node();
 * see the documentation there for more details.
 */
#define kthread_create(threadfn, data, namefmt, arg...)
 kthread_create_on_node(threadfn, data, NUMA_NO_NODE, namefmt, ##arg)
struct task_struct *kthread_create_on_cpu(int (*threadfn)(void *data),
 void *data,
 unsigned int cpu,
 const char *namefmt);
 
/**
 * kthread_run - create and wake a thread.
 * @threadfn: the function to run until signal_pending(current).
 * @data: data ptr for @threadfn.
 * @namefmt: printf-style name for the thread.
 *
 * Description: Convenient wrapper for kthread_create() followed by
 * wake_up_process(). Returns the kthread or ERR_PTR(-ENOMEM).
 */
#define kthread_run(threadfn, data, namefmt, ...)
({
 struct task_struct *__k
 = kthread_create(threadfn, data, namefmt, ## __VA_ARGS__);
 if (!IS_ERR(__k))
 wake_up_process(__k);
 __k;
})

kthread_create_on_node() 是要创建的kthread的实例化函数(作为参数接收),封装在类型为kthread_create_info的结构体中,并将其加入kthread_create_list链表尾部。然后它唤醒kthreadd并等待线程创建完成:

/* kernel/kthread.c */
static struct task_struct *__kthread_create_on_node(int (*threadfn)(void *data),
 void *data, int node,
 const char namefmt[],
 va_list args)
{
 DECLARE_COMPLETION_ONSTACK(done);
 struct task_struct *task;
 struct kthread_create_info *create = kmalloc(sizeof(*create),
 GFP_KERNEL);
 
 if (!create)
 return ERR_PTR(-ENOMEM);
 create->threadfn = threadfn;
 create->data = data;
 create->node = node;
 create->done = &done;
 
 spin_lock(&kthread_create_lock);
 list_add_tail(&create->list, &kthread_create_list);
 spin_unlock(&kthread_create_lock);
 
 wake_up_process(kthreadd_task);
 /*
 * Wait for completion in killable state, for I might be chosen by
 * the OOM killer while kthreadd is trying to allocate memory for
 * new kernel thread.
 */
 if (unlikely(wait_for_completion_killable(&done))) {
 /*
 * If I was SIGKILLed before kthreadd (or new kernel thread)
 * calls complete(), leave the cleanup of this structure to
 * that thread.
 */
 if (xchg(&create->done, NULL))
 return ERR_PTR(-EINTR);
 /*
 * kthreadd (or new kernel thread) will call complete()
 * shortly.
 */
 wait_for_completion(&done); // wakeup on completion of thread creation.
 }
...
...
...
}
 
struct task_struct *kthread_create_on_node(int (*threadfn)(void *data),
 void *data, int node,
 const char namefmt[],
 ...)
{
 struct task_struct *task;
 va_list args;
 
 va_start(args, namefmt);
 task = __kthread_create_on_node(threadfn, data, node, namefmt, args);
 va_end(args);
 
 return task;
}

回想一下,kthreadd调用create_thread()例程,根据加入链表中的数据来启动内核线程。 这个例程创建线程并指示完成:

/* kernel/kthread.c */
static void create_kthread(struct kthread_create_info *create)
{
 int pid;
 
 #ifdef CONFIG_NUMA
 current->pref_node_fork = create->node;
 #endif
 
 /* We want our own signal handler (we take no signals by default). */
 pid = kernel_thread(kthread, create, CLONE_FS | CLONE_FILES |
 SIGCHLD);
 if (pid < 0) {
 /* If user was SIGKILLed, I release the structure. */
 struct completion *done = xchg(&create->done, NULL);
 
 if (!done) {
 kfree(create);
 return;
 }
 create->result = ERR_PTR(pid);
 complete(done); /* signal completion of thread creation */
 }
}

do_fork()和copy_process()

到目前为止,我们所讨论的所有进程/线程创建调用接口都会调用不同的系统调用(create_thread除外)来进入内核模式。所有这些系统调用最终又会落到共同的内核函数_do_ fork()中,该函数将使用不同的CLONE_*标志进行调用。_do_fork()在内部会通过copy_process()来完成任务。图1-7总结了进程创建的调用顺序。

/* kernel/fork.c */
/*
 * Create a kernel thread.
 */
pid_t kernel_thread(int (*fn)(void *), void *arg, unsigned long flags)
{
 return _do_fork(flags|CLONE_VM|CLONE_UNTRACED, (unsigned long)fn,
 (unsigned long)arg, NULL, NULL, 0);
}
 
/* sys_fork: create a child process by duplicating caller */
SYSCALL_DEFINE0(fork)
{
#ifdef CONFIG_MMU
 return _do_fork(SIGCHLD, 0, 0, NULL, NULL, 0);
#else
 /* cannot support in nommu mode */
 return -EINVAL;
#endif
}
 
/* sys_vfork: create vfork child process */
SYSCALL_DEFINE0(vfork)
{
 return _do_fork(CLONE_VFORK | CLONE_VM | SIGCHLD, 0,
 0, NULL, NULL, 0);
}
 
/* sys_clone: create child process as per clone flags */
 
#ifdef __ARCH_WANT_SYS_CLONE
#ifdef CONFIG_CLONE_BACKWARDS
SYSCALL_DEFINE5(clone, unsigned long, clone_flags, unsigned long, newsp,
 int __user *, parent_tidptr,
 unsigned long, tls,
 int __user *, child_tidptr)
#elif defined(CONFIG_CLONE_BACKWARDS2)
SYSCALL_DEFINE5(clone, unsigned long, newsp, unsigned long, clone_flags,
 int __user *, parent_tidptr,
 int __user *, child_tidptr,
 unsigned long, tls)
#elif defined(CONFIG_CLONE_BACKWARDS3)
SYSCALL_DEFINE6(clone, unsigned long, clone_flags, unsigned long, newsp,
 int, stack_size,
 int __user *, parent_tidptr,
 int __user *, child_tidptr,
 unsigned long, tls)
#else
SYSCALL_DEFINE5(clone, unsigned long, clone_flags, unsigned long, newsp,
 int __user *, parent_tidptr,
 int __user *, child_tidptr,
 unsigned long, tls)
#endif
{
 return _do_fork(clone_flags, newsp, 0, parent_tidptr, child_tidptr, tls);
}
#endif

图1-7

1.7 进程状态和终止

在一个进程的整个生命周期中,它在最终终止之前会经历很多不同的状态。而用户必须要有适当的机制来更新其生命周期中所发生的一切。Linux为此提供了一组函数。

1.7.1 wait

对于由父进程创建的进程和线程,父进程想要了解其子进程/线程的执行状态,在功能上也许是有用的。这可以使用系统调用的wait函数族来实现:

#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *status);
pid_t waitpid(pid_t pid, int *status, intoptions);
int waitid(idtype_t idtype, id_t id, siginfo_t *infop, int options)

这些系统调用更新调用进程关于子进程的状态更改事件。以下状态更改事件会被通知:

子进程终止;

被信号停止;

被信号恢复。

除了报告状态,这些API还允许父进程收回已经终止的子进程。终止的进程会进入僵尸状态,直到当前的父进程调用wait来收回它的子进程为止。

1.7.2 exit

每个进程都必须结束。进程终止由进程调用exit()或主函数返回时完成。一个进程也可能会在接收到强制终止的信号或出现迫使其终止的异常时突然终止,比如KILL命令,系统会发送一个信号来杀死进程,或者引发异常。终止后,进程将进入退出状态,直到当前的父进程将其收回。

exit调用sys_exit系统调用,它实际上调用了do_exit例程。do_exit主要执行以下任务(do_ exit设置了许多值,并多次调用相关内核例程以完成其任务):

获取子进程返回给父进程的退出码;

设置PF_EXITING标志,表示进程正在退出;

清理并回收该进程所持有的资源。这包括释放mm_struct,如果它正在等待一个IPC信号量,则从队列中移除,并释放文件系统数据和文件(如果有),然后调用schedule(),因为进程不再可执行了。

在do_exit执行完后,进程保持僵尸状态,并且该进程描述符仍然保持完整,以便其父进程收集它的状态,然后由系统回收资源。

1.8 命名空间和cgroup

登录到Linux系统的用户可以清晰透视各种系统实体,如全局资源、进程、内核和用户。例如,一个有效的用户可以访问系统上所有正在运行的进程的PID(不管它们属于哪个用户)。用户可以观察到系统上其他用户的存在,并且可以运行命令来查看全局系统全局资源的状态,如内存、文件系统挂载和设备。此类操作不被视为入侵或安全漏洞,因为系统始终保证一个用户/进程永远不能入侵其他用户/进程。

但是,在少数服务器平台上这种透明度是不合理的。例如,考虑提供平台即服务(Platform as a Service,PaaS)的云服务提供商。它们提供一个环境来托管和部署自定义客户端应用程序。它们管理运行时、存储、操作系统、中间件和网络服务,留给客户管理自己的应用程序和数据。PaaS服务被各种电子商务、金融、在线游戏和其他相关企业所使用。

为了给客户端提供高效和有效的隔离和资源管理,PaaS服务提供商使用了各种工具。他们为每个客户端虚拟化系统环境,以实现安全性、可靠性和健壮性。Linux内核以cgroup和命名空间的形式提供底层机制,用于构建可以虚拟化系统环境的各种轻量级工具。Docker就是这样一个基于cgroup和命名空间的框架。

命名空间从本质上来说是抽象、隔离和限制一组进程对各种系统实体(如进程树、网络接口、用户ID和文件系统挂载)的可见性的机制。命名空间被分成几个组,而我们可以直接看到。

1.8.1 挂载命名空间

传统上,挂载和卸载操作将改变系统中所有进程所看到的文件系统视图。换句话说,所有进程都会看到一个全局挂载命名空间。挂载命名空间将文件系统挂载点的集合限制在一个进程的命名空间内可见,使挂载命名空间中的一个进程组与另一个进程相比具有文件系统列表的独有视图。

1.8.2 UTS命名空间

这使得在一个uts命名空间内能够隔离系统的主机和域名。这使初始化和配置脚本时能够基于各自的命名空间得到指引。

1.8.3 IPC命名空间

这些方法将进程与使用System V和POSIX消息队列区分开来。这样可以防止一个进程从IPC命名空间访问另一个进程的资源。

1.8.4 PID命名空间

传统上,*nix内核(包括Linux)在系统引导期间使用PID 1生成init进程,该进程进而依次启动其他用户模式进程并被视为整棵进程树的根(所有其他进程都在此进程树下启动)。PID命名空间允许进程使用其自己的根进程(PID 1进程)分离出它下面的新进程树。PID命名空间隔离进程ID号,并允许在不同的PID命名空间中复制PID号,这意味着不同PID命名空间中的进程可以具有相同的进程ID。PID命名空间内的进程ID是唯一的,并且从PID 1开始按顺序分配。

1.8.5 网络命名空间

这种类型的命名空间提供了网络协议服务和接口的抽象化和虚拟化。每个网络命名空间都有自己的网络设备实例,可以使用单独的网络地址进行配置。而对其他网络服务(路由表、端口号等)启用了隔离。

1.8.6 用户命名空间

用户命名空间允许进程在命名空间内外使用唯一的用户ID和组ID。这意味着一个进程可以在用户命名空间内部使用特权用户和组ID(0),并在命名空间外部继续使用非零用户和组ID。

1.8.7 cgroup命名空间

cgroup命名空间虚拟化/proc/self/cgroup文件的内容。cgroup命名空间内的进程只能查看相对于其命名空间根目录的路径。

1.8.8 控制组(cgroup)

cgroup是限制和度量每个进程组资源分配的内核机制。使用cgroup可以分配资源,例如CPU时间、网络和内存。

与Linux中的进程模型类似,每个进程都是父进程的子进程,并且在关系上都源自init进程,从而形成单一树状结构。而cgroup是分层结构,其中子cgroup继承父cgroup的属性,但不同之处在于多个cgroup可以存在于单个系统中,其中每个cgroup又具有不同的资源特权。

在命名空间上应用cgroup会导致将进程隔离到系统中的容器中,资源在这些容器中会得到不同的管理。每个容器都是一个轻量级的虚拟机,所有这些虚拟机都作为单独的实体运行,并且可以忽视同一系统中的其他实体的存在。

下面是Linux手册页中描述的名称空间API:

clone(2)
The clone(2) system call creates a new process. If the flags argument of the call 
specifies one or more of the CLONE_NEW* flags listed below, then new namespaces are
created for each flag, and the child process is made a member of those namespaces.(This
system call also implements a number of features unrelated to namespaces.)
setns(2)
The setns(2) system call allows the calling process to join an existing namespace.
The namespace to join is specified via a file descriptor that refers to one of the /
proc/[pid]/ns files described below.
unshare(2)
The unshare(2) system call moves the calling process to a new namespace. If the 
flags argument of the call specifies one or more of the CLONE_NEW* flags listed below,
then new namespaces are created for each flag, and the calling process is made a member
of those namespaces. (This system call also implements a number of features unrelated
to namespaces.)
Namespace Constant         Isolates
Cgroup    CLONE_NEWCGROUP  Cgroup root directory
IPC       CLONE_NEWIPC     System V IPC, POSIX message queues
Network   CLONE_NEWNET     Network devices, stacks, ports, etc.
Mount     CLONE_NEWNS      Mount points
PID       CLONE_NEWPID     Process IDs
User      CLONE_NEWUSER    User and group IDs
UTS       CLONE_NEWUTS     Hostname and NIS domain name

1.9 小结

我们了解了Linux的主要抽象之一——进程,以及促进这种抽象运行的整个生态系统。现在的挑战仍然是通过提供公平的CPU时间来运行大量的进程。随着多核系统对进程实施多种策略和优先级,对确定性调度的需求显得至关重要。

下一章将深入探讨进程调度,这是进程管理的另一个关键部分,并理解Linux调度器是如何设计来处理这种多样性的。

相关图书

Linux常用命令自学手册
Linux常用命令自学手册
庖丁解牛Linux操作系统分析
庖丁解牛Linux操作系统分析
Linux后端开发工程实践
Linux后端开发工程实践
轻松学Linux:从Manjaro到Arch Linux
轻松学Linux:从Manjaro到Arch Linux
Linux高性能网络详解:从DPDK、RDMA到XDP
Linux高性能网络详解:从DPDK、RDMA到XDP
跟老韩学Linux架构(基础篇)
跟老韩学Linux架构(基础篇)

相关文章

相关课程