曾与宇文拓共事五年,他对技术的钻研、热爱与执着一直历历在目,挥之不去。他的文章融会贯通于孜孜不倦的技术实践和深入浅出的明理阐释之中,言之有物,读之有味,非常适用于有志于进一步深入研究 Linux 系统编程的人员,可以避免走弯路,是一门非常实用的课程。
——王小强,诺基亚技术经理、资深咨询工程师
对于有一定 Linux 开发基础希望进阶学习 Linux 系统编程的开发人员来说,其难点在于,Linux 所囊括的技术点繁杂,往往不知从何下手。
而对于稍有些系统编程经验的 Linux 开发者来说,难点往往在于,缺乏对底层技术的透彻理解,而不能自如地把握和控制好系统的复杂行为。同时,底层技术也经常成为中级开发者晋升到高级职位的瓶颈。
本达人课结合作者近十年 Linux 开发经验,帮助大家系统掌握核心技术点,再结合真实场景案例,深入理解背后的工作原理和设计思想。
宇文拓,近十年 Linux C/C++ 开发经验,现就职于某创业公司,负责服务器架构与系统设计。曾就职于某通信业知名美企,负责核心网和防火墙产品研发。在 GitHub 上发布了开源项目 AndroidMemTracer。
Linux 的优秀之处自然不必多说。如果将操作系统比作一辆汽车,那 Linux 就是一辆性能出色的多功能越野车,上山下海飞天无所不能。
如果你拥有了它,一定不会只满足于驾驶它上下班,不能只会挂挡、踩油门和控制方向之类的基本操作。我们想充分了解并掌握它,挖掘其更多潜能。
但是,这个过程有相当的难度。
我们来看看掌握 Linux 的拦路虎。
对于有一定 Linux 开发基础希望进阶学习 Linux 系统编程的开发人员来说,其难点在于,Linux 所囊括的技术点繁杂,往往不知从何下手
对于稍有些系统编程经验的 Linux 开发者来说,难点往往在于,缺乏对底层技术的透彻理解,而不能自如地把握和控制好系统的复杂行为。同时,底层技术也经常成为中级开发者晋升到高级职位的瓶颈
Linux 内容纷繁复杂,会让学习者不知从何处下手。虽说 Linux 开放了所有设计图纸,可如果将这些图纸全部打印出来,可能要装满整个房间,即使号称 Linux 源码航海图的《深入理解 Linux 内核》图书也有 800 多页。如果毫无方向地从头看起,看不了几页可能就昏昏欲睡了。
在我最开始接触 Linux 系统开发,还没有多少真实项目经验时,就曾数次扑进 Linux 设计图纸里,但每次都坚持不了几个月,然后带着受伤的自信心惨败而归。
对于 Linux 开发者,这时如果能有一本 Linux 航海图的阅读指引,标注出航海图上宝藏的埋藏位置和宝藏简介,一定可以让学习过程更有方向性,也更加轻松愉悦。
开发者在最初开发 Linux 系统上的应用和服务时,往往会将大量精力倾注在实现业务需求上,对底层技术的实现细节并没有特别深刻的理解。
但随着系统复杂性的提高,开发人员只有对底层实现细节有越来越透彻的理解,才能更好地把握和控制系统的复杂行为。否则一旦遇到如下这些问题,将很难从根源上解决。
程序在某个位置出现间歇性崩溃,可当我添加一行调试用的日志后,就再也没崩溃过了,这背后到底是怎么回事呢?
我写了一个多进程模型的服务器,但总感觉新进程启动地不干净,有时会有些父进程的东西掺和到子进程里来。可如果让父进程在启动子进程之前做更多的计算,或者单纯多等一会,这种情况发生的概率便大大减少了,该系统的行为让人有点捉摸不透,其背后的原因是什么呢?
我的信号处理程序与主程序使用同一个共享变量来交换信息,大多数时候都能正常工作,可总会时不时抽风一下,代码都已仔细检查过,找不出任何逻辑上的问题。这让人非常苦恼,到底是哪里出现问题了?
类似这种程序表现与预期不完全一致的情况会让开发者非常头疼,尤其是那种大部分时间表现正常,偶尔会“抽风”的情况。
以上令人头疼的问题,我不止一次地经历过,数次被 Linux 伤害自信的感觉至今仍记忆犹新。
幸运的是,在经历了多次探索、踩坑之后,Linux 系统的行为特点以及如此设计的内在原因终于在我眼前逐渐明晰起来,这个系统内部精巧的设计也越来越清晰地展现在我眼前。
如今,我已从事 Linux 开发近十年,再回想那段在 Linux 世界中几度迷失的经历,已经能够明确知道问题出在了哪里——还不知道它能做什么时,便试图弄清它是如何运作的。正如还不知道一辆车发动后如何前进,便开始研究发动机、变速箱里的那一堆齿轮、联动部件,结果自然是事倍功半。
相信很多 Linux 开发者都曾经或正在遭遇与我类似的痛苦经历。
为了帮助大家度过难关,实现 Linux 快速高效学习,我将多年积累的学习和研发心得整理成了《攻克 Linux 系统编程》达人课。希望带领大家通身打量 Linux 系统,抓住核心问题,全面解析其中的每个功能点,再结合来自真实需求的案例,进一步深入理解系统提供的功能、工作原理,以及设计思想。
课程中的每一课都将围绕几个很有实用价值的系统知识点展开,通过深入分析带读者一步步理解 Linux 系统,扩展自身技能的广度和深度。
课程主要分为三大部分。
第一部分(第 01~08 课):基础知识篇。学习 Linux,首先要对它的常用功能有全面了解。通过本部分的学习,读者可以加深理解这些常用技术,更好地把控正在开发的应用。同时,Linux 在某些问题上的解决思路,也可以为读者的日常工作提供很有价值的参考。
第二部分(第 09~17 课):进阶知识篇,深入讨论 Linux 系统的高级特性和功能。这些特性和功能在日常开发中可能不像第一部分内容那么常用,但掌握了这些知识,读者更有能力实现功能更加强大的应用。在某些篇章中,我还会带大家深入到内核,甚至硬件去窥探其设计和实现原理,从而让读者对该系统的理解更加透彻。
第三部分(第 18~25 课):实战案例篇,选取了兼具商业价值与技术挑战性的八个主题,详细讨论它们的设计思路和实现方案。在该部分中,读者可以通过实际案例磨练提高自身的技术能力,同时获得宝贵的商业项目实战经验,扩展技术能力的广度。
本课程浓缩了我近十年开发经验精华,不仅帮你深入了解 Linux 这辆车所提供的各种功能,更能获得宝贵的商业项目开发经验,轻松自如驾驭它。有时为了帮助大家更深刻理解某一部分内容,我们还会拆开它的“外壳”,深入到内部一探究竟。
希望通过该课程,Linux 应用开发者可以进一步丰富自己的技能树和工具箱,开发出更加出色、功能更加强大的应用。而 Linux 内核初学者,也可以在开始内核之旅之前,先通过该课程对系统有个整体全面的认识,在真正深入研究设计图纸时,脑海中能有个全景图,避免自己迷失在大量的精巧细节中。
面对 Linux 这辆多功能越野车,如果你希望掌握更加酷炫的驾驶技术,那么本课程一定不会让你失望。
点击了解:《攻克 Linux 系统编程》
本课程主要带大家深入研究 Linux 系统编程。系统编程的任务,可以定义为使用系统提供的功能解决我们面对的实际问题,而系统调用,则是系统开放给应用执行特定功能的接口。本文首先从 Linux 系统调用讲起,主要包括以下内容:
另外,还会扩展两个知识点:
系统调用是操作系统内核提供给应用程序的基础接口,需要运行在操作系统的核心模式下,以确保有权限执行某些 CPU 特权指令。
Linux 系统提供了功能非常丰富的系统调用,涵盖了文件操作、进程控制、内存管理、网络管理、套接字操作、用户管理、进程间通信等各个方面。
执行如下命令,可列出系统中所有的系统调用名称。
man syscalls
Linux 自带的 man 手册对每个系统调用都进行了非常详细的说明,包括函数功能、传入的参数、返回值,以及可能产生的错误、使用注意事项,等等,其完善程度丝毫不亚于微软的 MSDN。虽然是英文版,但读起来比较通俗易懂,每位 Linux 系统开发者都应该习惯于查看这些文档。
另外,IBM 文档库里有一份质量非常高的《中文版系统调用列表》,阅读它会更轻松。
我们先看第一种方式。
系统调用由指派的编号来标识,通过 syscall 函数以编号为参数可直接被调用。
syscall 函数原型为:
int syscall(int number, ...);
完整的系统调用编号都定义在 sys/syscall.h 文件中。感兴趣的读者可以自行查看。
显然,记忆如此多的编号,对开发者很不友好。
于是,开发者多会选择第二种方式,即利用 glibc 提供的包装函数将这些系统调用包装成名字自解释的函数。
这个过程,包装函数并没有做太多额外工作,主要是检查参数,将它们拷贝到合适的寄存器中,接着调用指定标号的系统调用,之后再根据结果设置 errno,供应用程序检查执行结果,以及其他相关工作。
两种调用方式,在功能上可以认为是完全等价的,但在易读、易用性上,glibc 包装函数则更有优势。在之后的课程中,我提到某系统调用,若无特殊说明,指的便是 glibc 包装函数。
当然,如果包装函数无法满足某些特殊应用场景需求,还可以使用 syscall 函数直接执行系统调用。不过这种情况非常少见,到目前为止,我还没有遇到过。
系统调用的实现代码是内核代码的一部分。执行系统调用代码,首先需要将系统从用户模式切换到核心模式。
早期的系统调用通过软中断实现模式的切换,而中断号属于系统稀缺资源,不可能为每个系统调用都分配一个中断号。
在 Linux 的实现中,所有的系统调用共用 128 号中断(也就是大名鼎鼎的 int 0x80 ),其对应的中断处理程序是 system_call,所有的系统调用都会转到这个中断处理程序中。
接着,system_call 会根据 EAX 传入的系统调用标号跳转并执行相应的系统调用程序。如果需要更多的参数,会依次用 EBX、ECX、EDX、EDI 进行传递。函数执行完成之后,会把结果放到 EAX 中返回给应用程序。
由此可知,一次系统调用便会触发一次完整的中断处理过程。在每次中断处理过程中,CPU 都会从系统启动时初始化好的中断描述表中,取出该中断对应的门描述符,并判断门描述符的种类。
在确认门描述符的级别(DPL)不比中断指令调用者的级别(CPL)低之后,再根据描述符的内容,将中断处理程序中可能用到的寄存器进行压栈保存。最后执行权限提升,设置 CS 和 EIP 寄存器,以使 CPU 跳转到指定的系统调用的代码地址,并执行目标系统调用。
再仔细审视基于中断方式的系统调用的执行过程,不难发现,前面很多处理过程都是固定的,其实很没必要,如门描述符级别检查、查找中断处理程序入口,等等。
为了省去这些多余的检查,Intel 在 Pentium II CPU 中加入了新的 SYSENTER 指令,专门用来执行系统调用。
该指令会跳过前面检查步骤,直接将 CPU 切换到特权模式,继而执行系统调用,同时还增加了几个专用寄存器辅助完成参数传递和上下文保存工作。另外,还相应地增加了 SYSEXIT 指令,用来返回执行结果,并切回用户模式。
在 Linux 实现了 SYSENTER 方式的系统调用之后,就有人用 Pentium III 的机器对比测试了两种系统调用的效率。测试结果显示,与中断方式相比,SYSENTER 在用户模式下因省掉了级别检查类的操作,花费的时间大幅减少了 45% 左右;在核心模式下,因少了一个寄存器压栈保存动作,所花费的时间也减少了 2% 左右。
目前,基于中断方式的系统调用仍然保留着,Linux 启动时会自动检测 CPU 是否支持 SYSENTER 指令,从而根据情况选择相应的系统调用方式。
介绍完了 SYSENTER 指令的优越之处,我们回过头再来聊聊它的由来。
从 Linux 2.5 内核开始,在经历了多方测试、多次 Patch 之后,SYSENTER 指令才正式被 Linux 2.6 版本支持,且由 Linus Torvalds 大神亲自操刀实现。
上面提到过,其实早在 1998 年,SYSENTER 指令就已经引入到 Intel Pentium II CPU 中,直到 2002 年才在 Linux 2.5 版本的内核中出现。该指令一出现,Linux 社区就开始了激烈讨论。
后来 Intel Pentium 4 CPU 发布了,这款 CPU 在“设计上存在的问题,造成 Pentium 4 使用中断方式执行系统调用比 Pentium 3 以及 AMD Athlon 所耗费的 CPU 时钟周期多 5~10 倍”,Linus 对这个结果接受不了,于是在 Linux 2.6 内核中加入了 SYSENTER 指令,从而实现了更加高效的系统调用。
最后总结下系统调用的执行过程。进程从用户模式转入核心模式,开始执行内核中实现特定功能的代码段,执行完成后再切回用户模式,并把执行结果返回给调用进程。在 Linux 2.4 版本之前,主要利用中断方式实现核心模式的切换;Linux 2.6 及以后版本的内核中,可以利用更高效的 SYSENTER 指令实现。
想了解更详细的技术细节,大家可以阅读内核代码,对应的文件是 arch/i386/kernel/vsyscall-sysenter.S。当然,在 glibc 中也需做相应的修改,即把 int 0x80 替换成 call xxxx,xxxx 为执行系统调用的函数地址。
前面提到,本课程所说的系统调用,默认是指 glibc 中的包装函数。这些函数会在执行系统调用前设置寄存器的状态,并仔细检查输入参数的有效性。系统调用执行完成后,会从 EAX 寄存器中获取内核代码执行结果。
内核执行系统调用时,一旦发生错误,便将 EAX 设置为一个负整数,包装函数随之将这个负数去掉符号后,放置到一个全局的 errno 中,并返回 −1。若没有发生错误,EAX 将被设置为 0,包装函数获取该值后,并返回 0,表示执行成功,此时无需再设置 errno。
综上,系统调用的标准使用方法可总结为:根据包装函数返回值的正负,确定系统调用是否成功。如果不成功,进一步通过 errno 确定出错原因,根据不同的出错原因,执行不同的操作;如果成功,则继续执行后续的逻辑。代码示例如下:
int ret = syscallx(...);if(ret < 0){ //有错误了,通过 errno 确定出错的原因,执行不同的操作}else{ //调用成功,继续干活}
大多数系统调用都遵循这一过程,errno 是一个整数,可以用 perror 或 strerror 获得对应的文字描述信息。
不过,也有几个特殊的系统调用,和上述使用方法存在些许差异。比如,其中有个函数会在调用之前将 errno 重置为 0,调用后,通过检查 errno 判断执行是否成功。此类函数只有非常少数的几个,使用之前,看看帮助页,就知道如何使用了。
系统调用的使用规范就介绍到这里。此时,你可能有个疑问,每个系统调用失败后都会设置 errno,如果在多线程程序中,不同线程中的系统调用设置的 errno 会不会互相干扰呢?
如果 errno 是一个全局变量,答案是肯定的。如果真是这样的话,那系统调用的局限性也就太大了,总不能在每个系统调用之前都加锁保护吧。优秀的 Linux 肯定不会这么弱,那么,这个 errno 的问题又是怎么解决的呢?
根据 man 手册,要使用 errno,首先需要包含 errno.h 这个头文件。我们先看看 errno.h 里面有什么东西。
vim /usr/include/errno.h
执行以上代码,会发现该文件中有这样几行关键内容:
#include .......#ifndef errnoextern int errno;#endif
根据官方提供的代码注释,bits/errno.h 中应该有一个 errno 的宏定义。如果没有,则会在外部变量中寻找一个名为 errno 的整数,它自然也就成了全局整数。否则,这个 errno 只是一个 per-thread 变量,每个线程都会拷贝一份。
关于 per-thread 变量更详细的信息,我们会在后面的课程中介绍。现在,你只需知道,这个 errno,每个线程都会独立拷贝一份,所以在多线程程序中使用它是不会相互影响的。
具体是怎么做到的呢?我们可以再打开 bits/errno.h 看一眼。
# ifndef __ASSEMBLER__extern int *__errno_location (void) __THROW __attribute__ ((__const__));# if !defined _LIBC || defined _LIBC_REENTRANT# define errno (*__errno_location ())# endif# endif
原来,当 libc 被定义为可重入时,errno 就会被定义成一个宏,该宏调用外部 __errno_location 函数返回的内存地址中所存储的值。在 GCC 源码中,我们还发现一个测试用例中定义了 __errno_location 函数的 Stub,是这样写的:
extern __thread int __libc_errno __attribute__ ((tls_model ("initial-exec")));int * __errno_location (void){ return &__libc_errno;}
这一简单的测试用例充分展现了 errno 的实现原理。errno 被定义为 per-thread(用 __thread 标识的线程局部存储类型)变量 __libc_errno,之后 __errno_location 函数返回了这个线程局部变量的地址。所以,在每个线程中获取和设置 errno 的时候,操作的是本线程内的一个变量,不会与其他线程相互干扰。
至于 __thread 这个关键字,需要在很“严苛”的条件下才能生效——需要 Linux 2.6 以上内核、pthreads 库、GCC 3.3 或更高版本的支持。不过,放到今天,这些条件已成为标配,也就不算什么了。
上面只是解释了在多线程中使用系统调用时,errno 不会发生冲突问题,但并不是说所有的系统调用都可以放心大胆地在多线程程序中使用。
有一些系统调用,标准中并没有规定它们的实现必须是多线程安全的(或者说可重入的,后面的课程中再详细解释)。由于历史原因和实现原理上的限制,有些函数的实现并不是线程安全的,比如 system()。某些 glibc 函数也是一样,比如 strerror 函数,其内部使用一块静态存储区存放 errno 描述性信息,最近的一次调用会覆盖上一次调用的内容。
glibc 还额外为一些函数提供了多线程安全实现版本,大多数是在原函数名后加上 _r 后缀,比如一些时间操作类的函数。实现原理是让应用单独提供缓冲区,而不再使用同一块静态缓冲区。更多细节信息,后面讲到线程时,再详细展开。
作为本课程的第一课,我们先从总体上认识了 Linux 系统调用,概要地介绍了系统调用的执行过程。还顺带介绍了 Linux 系统调用方式的发展小历史。
随后,我们介绍了使用系统调用的标准套路,顺带深入探究了 errno 的多线程解决方法。
希望这些内容对你当前的工作有所启发。最后再说一句,Linux 系统开发者,一定要多查看 Linux 帮助文档。
点击了解:《攻克 Linux 系统编程》
在 Linux 中有条重要的哲学,即一切皆文件,本文就来着重讲讲文件操作。首先从磁盘文件开始,探讨除打开、读、写、关闭等常规操作外,还有哪些可控操作。理解和掌握各个步骤的行为细节,可以帮助开发者写出性能更好、更加稳健的应用。本文主要包括以下几部分内容:
要想更精准地控制文件读写行为,首先要弄清楚,在不做任何额外控制的情况下,系统默认动作是什么。了解了这些,我们才能知道默认行为适合哪些情况,而在哪些情况下需要我们介入附加控制。
我们首先分别看下文件读取、文件写入的系统默认动作。
先说文件读取
默认情况下,当使用 read 系统调用从文件中读取一些字节时,Linux 内核除了读取指定字节数的数据外,还会额外预读取一些数据到内核缓冲区。下次再读取文件内容时,会先从内核缓冲区中查找,如果正好找到了,则省去了等待慢速磁盘定位和数据传输的时间。在大多数 Linux 系统中,预读取数据的长度为 128 KB,也可能根据系统可用内存的大小动态调整。
再看文件写入
当用 write 系统调用写入文件内容时,函数将数据回写到内核缓冲区之后便返回,Linux 内核负责在稍后一段时间内将文件内容真正写入到磁盘中。除了更新文件本身的内容,还会更新文件的元数据,如文件大小、文件关联的 inode、文件最后修改时间等信息。
默认的文件读写操作已能很好地满足绝大多数应用的需求,但仍有一些特殊的读写操作需做特别处理。
比如,某些应用对数据可靠性有很高要求,某段代码逻辑需保证文件内容确实已经更新到磁盘上后,才会向下运行。显然,默认的写操作(内容会在稍后一段时间内由操作系统内核写入磁盘)是无法满足这一需求的。
还有,某些应用在读取数据时有固定的访问模式,比如读取某段数据之后,后续肯定会再读取文件某偏移处的数据,这时需针对该固定访问模式做深度优化。
还有一些数据库应用,已在应用层实现了自己的数据管理策略,当使用 write 系统调用时,希望系统将数据直接写入磁盘,无需缓冲数据,进而节省内核资源。
另外,还有一点需要注意,系统调用在内核中已经实现了一套高速缓存,但这并不意味着,应用可以随意执行系统调用,而不用关心性能问题。倘若每次使用系统调用读取或写入的字节数很少,系统调用本身的开销将占用总开销很大比例。因此,在应用层上加一层数据缓存,以尽量减少系统调用的次数,可明显提升应用的性能表现。
本文接下来依次讨论各个层次上的那些可控制选项。
先从应用层开始,谈谈应用层缓存问题。
前面提到,应用层应该加一层缓存,以尽量减少系统调用的使用次数,以此来提高应用的整体性能表现。那么该缓存大小应该如何确定呢?
在某文献中有这样一个测试,以 Ext2 文件系统中的常规文件为例,当该文件系统的块大小为 4K 字节时,在应用层设置 4K 字节的缓存,相比单字节调用系统调用,性能可以提升两个数量级。进一步增大应用层的缓存大小,性能不再有明显提升。基于这个测试结果,我们可以得出,应用层缓存大小至少应该等于所使用文件系统的块大小。
实际上,还有一个获取文件信息的系统调用 stat。其获得的文件属性信息中,有一项建议了文件 IO 缓存大小,低于此值的缓存大小会被认为是低效的。其函数原型为:
int stat(const char * pathname, struct stat *statbuf);
这一项正是 struct stat 结构体中的 st_blksize 字段。设置应用层的缓存大小,至少不小于该字段给出的数值。
此外,glibc 中提供的 fread 和 fwrite 函数,其内部都维护了一个数据缓存,用来尽量减少系统调用次数。默认选择的缓存大小已进行了充分优化。如果还是不满意,可以用 glibc 的 setvbuf 和 setbuffer 函数自定义缓存大小和缓存行为。这两个函数的原型分别为:
int setvbuf(FILE *stream, char *buf, int mode, size_t size);void setbuffer(FILE *stream, char *buf, size_t size);
其中的 setvbuf 函数允许开发者指定缓冲方式,主要有以下三种可选方式。
出于性能上的考虑,读写磁盘文件应该使用 fread 和 fwrite 函数,而不是直接使用 read 和 write 系统调用。同时,可以使用库函数默认的缓冲区,也可以根据 stat 的建议设置合适大小的自定义缓冲区。
知道了如何设置缓冲区,下面就来说说如何控制数据在磁盘上的更新。
在 Linux 内核中,文件数据的磁盘同步状态有两个层次,分别为“同步 IO 数据完整性”和“同步 IO 文件完整性”,名称有点长,后面我们简称为“数据完整性”和“文件完整性”。
文件的元数据是指描述文件信息的数据,如文件创建时间、大小、占用节点数据等。文件完整性包含了数据完整性。还有一点需要明确,文件的元数据和文件数据并未保存在同一磁盘位置上。
Linux 提供了 fdatasync 和 fsync 两个系统调用,分别用来保证数据完整性和文件完整性。函数原型分别为:
int fdatasync(int fd);int fsync(int fd);
所以,前面提到的对数据可靠性要求很高的应用,就可以在 write 之后调用 fdatasync,强制将数据从内核缓冲区刷新到磁盘中。
当然也可以调用 fsync ,更加彻底地把数据元数据信息也刷新到磁盘中去。
不过大家需要清楚,这样做会带来更高的操作延迟,因为文件的元数据通常保存在不同的磁盘位置上,需多花一些寻道时间来保存元数据。具体使用哪种层次的数据同步,可以根据应用需求灵活选择。
除此之外,使用 open 打开文件时指定 O_DSYNC 或 O_SYNC 标志,也可以让 write 系统调用在该文件上写入数据时,分别达到数据完整性、文件完整性同步状态,即让 write 系统调用在文件上的表现等同于 write + fdatasync 或 write + fsync。但是,使用这两个标志会影响到在该文件上的每一次 write 系统调用的执行。
编码时,推荐根据具体需求谨慎选择调用 fdatasync 或是 fsync。不建议使用打开标志,该方式会损失一些灵活性与性能。
如何根据数据访问顺序的特定模式深入优化应用,针对这个问题,Linux 内核提供了 posix_fadvise 系统调用。它允许应用程序告知内核访问某个文件数据时采取的模式。系统调用原型为:
int posix_fadvise(int fd, off_t offset, off_t len, int advice);
其中,advice 参数指定了所采用的模式,支持的模式有以下几种。
灵活使用这些参数,可以帮助内核对应用采取更加友好的行为策略,或者可以提高缓存的命中率,从而提高性能表现,或者可以节省内核所占用的宝贵内存资源。
最后,再来谈谈不使用内核缓冲区,直接与磁盘进行数据传输的问题。在 Linux 中,将这一过程称为直接 IO,实现方式是在打开文件的同时指定 O_DIRECT 标志。
使用这种方式写入文件时,内存边界需要是文件系统块大小的整数倍,传递数据的长度也需要是块大小的整数倍。这时,可能需要使用 memalign 此类技术来分配数据内存块。详细信息,请参看 Linux 手册,或自行 Google 查找专门的介绍材料,这里就不展开了。
经过上面的解说,我们已经知道如何应对某些对文件 IO 有特殊要求的需求了。不过,似乎还有最后一个问题有待解决。
前面讨论过,为了尽量减少系统调用的次数,我们推荐使用 glibc 的 fread 和 fwrite 函数操作文件,这两个函数需要的参数是 FILE 类型。
如果我们既希望使用系统调用控制数据同步和内核缓冲行为,又需要使用以整数型为参数的系统调用,该如何混合使用两者操作同一个文件呢?
其实,C 标准库提供了实现两者间互相转换的函数:
int fileno(FILE *fp)FILE * fdopen(int fd, const char * mode)
其中 fdopen 中的文件模式需要和 open 打开文件时的模式相同,否则会失败。
终于,最后一块拼图也完整了。
本文深入讨论了除常规打开、读写、关闭之外,对磁盘文件还可进行的其他更高级的控制功能,以及适用场景。希望它们能成为读者工具箱中的一部分,变成打磨读者自己应用的利器。
点击了解:《攻克 Linux 系统编程》
简单来讲,进程就是运行中的程序。更进一步,在用户空间中,进程是加载器根据程序头提供的信息将程序加载到内存并运行的实体。在本文中,我们就来深挖进程在用户空间内的更多细节,主要包括以下几部分内容:
在理解虚拟空间排布之前,先要明确虚拟空间的概念。上一篇,我们讲解的 ELF 文件头中指定的程序入口地址,各个节区在程序运行时的内存排布地址等,指的都是在进程虚拟空间中的地址。
虚拟空间可以认为是操作系统给每个进程准备的沙盒,就像电影《黑客帝国》中 Matrix 给每个人准备的充满营养液的容器一样。实际上,每个进程只存活在自己的虚拟世界里,却感觉自己独占了所有的系统资源(内存)。
当一个进程要使用某块内存时,它会将自己世界里的一个内存地址告诉操作系统,剩下的事情就由操作系统接管了。操作系统中的内存管理策略将决定映射哪块真实的物理内存,供应用使用。操作系统会竭尽全力满足所有进程合法的内存访问请求。一旦发现应用试图访问非法内存,它将会把进程杀死,防止它做“坏事”影响到系统或其他进程。
这样做,一方面为了安全,防止进程操作其他进程或者系统内核的数据;另一方面为了保证系统可同时运行多个进程,且单个进程使用的内存空间可以超过实际的物理内存容量。
该做法的另一结果则是降低了每个进程内存管理的复杂度,进程只需关心如何使用自己线性排列的虚拟地址,而不需关心物理内存的实际容量,以及如何使用真实的物理内存。
在 32 位系统下,进程的虚拟地址空间有 4G($2^{32}$ Bytes),其中的 1G 分配给了内核空间,用户应用可以使用剩余的 3G。在 64 位的 Linux 系统上,进程的虚拟地址空间可以达到 256TB,内核和应用分别占用 128TB。目前看来,这样的地址空间范围足够用了。
一个典型的内存排布结构如下图所示:
其中,#1 部分是上一篇中讨论过的内容,是按照 ELF 文件中的程序头信息,加载文件内容所得到的。除此之外,加载器还会为每个应用分配栈区(Stack)、堆区(Heap)和动态链接库加载区。栈和堆分别向相对的方向增长,系统会有相应的保护措施,阻止越界行为发生。
在 Linux 系统中,使用如下命令可查看一个运行中的进程的内存排布。
cat /proc/PID/maps
稍微修改上一篇中的示例代码,在 main 函数返回之前,增加一个无限循环,保持程序一直运行。
while(1) { sleep(1); }
启动程序并查看该进程的内存布局,可以看到如下所示的信息:
[root@TealCode process]# gcc -o process process.c[root@TealCode process]# ./process &[1] 3354[root@TealCode process]# Message In Main[root@TealCode process]# cat /proc/3354/maps00400000-00401000 r-xp 00000000 08:03 77368409 /home/TealCode/courses/process/process00600000-00601000 r--p 00000000 08:03 77368409 /home/TealCode/courses/process/process00601000-00602000 rw-p 00001000 08:03 77368409 /home/TealCode/courses/process/process00602000-0060c000 rw-p 00000000 00:00 07fcd2a3fb000-7fcd307fc000 rw-p 00000000 00:00 07fcd307fc000-7fcd309b2000 r-xp 00000000 08:03 86193 /usr/lib64/libc-2.17.so7fcd309b2000-7fcd30bb2000 ---p 001b6000 08:03 86193 /usr/lib64/libc-2.17.so7fcd30bb2000-7fcd30bb6000 r--p 001b6000 08:03 86193 /usr/lib64/libc-2.17.so7fcd30bb6000-7fcd30bb8000 rw-p 001ba000 08:03 86193 /usr/lib64/libc-2.17.so7fcd30bb8000-7fcd30bbd000 rw-p 00000000 00:00 07fcd30bbd000-7fcd30bdd000 r-xp 00000000 08:03 86186 /usr/lib64/ld-2.17.so7fcd30dc3000-7fcd30dc6000 rw-p 00000000 00:00 07fcd30dda000-7fcd30ddc000 rw-p 00000000 00:00 07fcd30ddc000-7fcd30ddd000 r--p 0001f000 08:03 86186 /usr/lib64/ld-2.17.so7fcd30ddd000-7fcd30dde000 rw-p 00020000 08:03 86186 /usr/lib64/ld-2.17.so7fcd30dde000-7fcd30ddf000 rw-p 00000000 00:00 07ffdc83c0000-7ffdc83e1000 rw-p 00000000 00:00 0 [stack]7ffdc83ed000-7ffdc83ef000 r-xp 00000000 00:00 0 [vdso]ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0 [vsyscall]
从以上输出的内容中,可以直观看到进程的段、堆区,动态链接库加载区,栈区的逻辑地址排布,以及每块内存区分配到的权限等。
除此之外,还有两块 vdso 和 vsyscall 内存区。它们是一部分内核数据在用户空间的映射,为了提高应用的性能而创建。在后面的课程中,我们再专门详细讨论。
从用户角度来看,启动一个进程有许多种方式,可以配置开机自启动,可以在 Shell 中手动运行,也可以从脚本或其他进程中启动。
而从开发人员角度看,无非就是两个系统调用,即 fork() 和 execve()。下面就来探究下这两个系统调用的行为细节。
fork() 系统调用将创建一个与父进程几乎一样的新进程,之后继续执行下面的指令。程序可以根据 fork() 的返回值,确定当前处于父进程中,还是子进程中——在父进程中,返回值为新创建子进程的进程 ID,在子进程中,返回值是 0。一些使用多进程模型的服务器程序(比如 sshd),就是通过 fork() 系统调用来实现的,每当新用户接入时,系统就会专门创建一个新进程,来服务该用户。
fork() 系统调用所创建的新进程,与其父进程的内存布局和数据几乎一模一样。在内核中,它们的代码段所在的只读存储区会共享相同的物理内存页,可读可写的数据段、堆及栈等内存,内核会使用写时拷贝技术,为每个进程独立创建一份。
在 fork() 系统调用刚刚执行完的那一刻,子进程即可拥有一份与父进程完全一样的数据拷贝。对于已打开的文件,内核会增加每个文件描述符的引用计数,每个进程都可以用相同的文件句柄访问同一个文件。
深入理解了这些底层行为细节,就可以顺理成章地理解 fork() 的一些行为表现和正确使用规范,无需死记硬背,也可获得一些别人踩过坑后才能获得的经验。
比如,使用多进程模型的网络服务程序中,为什么要在子进程中关闭监听套接字,同时要在父进程中关闭新连接的套接字呢?
原因在于 fork() 执行之后,所有已经打开的套接字都被增加了引用计数,在其中任一个进程中都无法彻底关闭套接字,只能减少该文件的引用计数。因此,在 fork() 之后,每个进程立即关闭不再需要的文件是个好的策略,否则很容易导致大量没有正确关闭的文件一直占用系统资源的现象。
再比如,下面这段代码是否存在问题?为什么在输出文件中会出现两行重复的文本?
int main(){ FILE * fp = fopen("output.txt", "w"); fputs("Message in parent\n", fp); switch(fork()) { case -1: perror("fork failed"); return -1; case 0: fputs("Message in Child\n", fp); break; default: break; } fclose(fp); return 0;}
输入文本:
[root@TealCode process]# cat output.txtMessage in parentMessage in parentMessage in Child
原因是 fputs 库函数带有缓冲,fork() 创建的子进程完全拷贝父进程用户空间内存时,fputs 库函数的缓冲区也被包含进来了。所以,fork() 执行之后,子进程同样获得了一份 fputs 缓冲区中的数据,导致“Message in parent”这条消息在子进程中又被输出了一次。要解决这个问题,只需在 fork() 之前,利用 fflush 打开文件即可,读者可自行验证 。
另外,希望读者自己思考下,利用父子进程共享相同的只读数据段的特性,是不是可以实现一套父子进程间的通信机制呢?
execve() 系统调用的作用是运行另外一个指定的程序。它会把新程序加载到当前进程的内存空间内,当前的进程会被丢弃,它的堆、栈和所有的段数据都会被新进程相应的部分代替,然后会从新程序的初始化代码和 main 函数开始运行。同时,进程的 ID 将保持不变。
execve() 系统调用通常与 fork() 系统调用配合使用。从一个进程中启动另一个程序时,通常是先 fork() 一个子进程,然后在子进程中使用 execve() 变身为运行指定程序的进程。 例如,当用户在 Shell 下输入一条命令启动指定程序时,Shell 就是先 fork() 了自身进程,然后在子进程中使用 execve() 来运行指定的程序。
execve() 系统调用的函数原型为:
int execve(const char *filename, char *const argv[], char *const envp[]);
filename 用于指定要运行的程序的文件名,argv 和 envp 分别指定程序的运行参数和环境变量。除此之外,该系列函数还有很多变体,它们执行大体相同的功能,区别在于需要的参数不同,包括 execl、execlp、execle、execv、execvp、execvpe 等。它们的参数意义和使用方法请读者自行查看帮助手册。
需要注意的是,exec 系列函数的返回值只在遇到错误的时候才有意义。如果新程序成功地被执行,那么当前进程的所有数据就都被新进程替换掉了,所以永远也不会有任何返回值。
对于已打开文件的处理,在 exec() 系列函数执行之前,应该确保全部关闭。因为 exec() 调用之后,当前进程就完全变身成另外一个进程了,老进程的所有数据都不存在了。如果 exec() 调用失败,当前打开的文件状态应该被保留下来。让应用层处理这种情况会非常棘手,而且有些文件可能是在某个库函数内部打开的,应用对此并不知情,更谈不上正确地维护它们的状态了。
所以,对于执行 exec() 函数的应用,应该总是使用内核为文件提供的执行时关闭标志(FD_CLOEXEC)。设置了该标志之后,如果 exec() 执行成功,文件就会被自动关闭;如果 exec() 执行失败,那么文件会继续保持打开状态。使用系统调用 fcntl() 可以设置该标志。
glibc 从 2.3.2 版本开始提供 fexecv() 函数,它与 execve() 的区别在于,第一个参数使用的是打开的文件描述符,而非文件路径名。
增加这个函数是为了满足这样的应用需求:有些应用在执行某个程序文件之前,需要先打开文件验证文件内容的校验和,确保文件内容没有被恶意修改过。
在这种情景下,使用 fexecve 是更加安全的方案。组合使用 open() 和 execve() 虽然可以实现同样的功能,但是在打开文件和执行文件之间,存在被执行的程序文件被掉包的可能性。
在 Linux 应用中,父进程需要监控其创建的所有子进程的退出状态,可以通过如下几个系统调用来实现。
pid_t wait(int * statua)
一直阻塞地等待任意一个子进程退出,返回值为退出的子进程的 ID,status 中包含子进程设置的退出标志。
pid_t waitpid(pid_t pid, int * status, int options)
可以用 pid 参数指定要等待的进程或进程组的 ID,options 可以控制是否阻塞,以及是否监控因信号而停止的子进程等。
int waittid(idtype_t idtype, id_t id, siginfo_t *infop, int options)
提供比 waitpid 更加精细的控制选项来监控指定子进程的运行状态。
wait3() 和 wait4() 系统调用
可以在子进程退出时,获取到子进程的资源使用数据。
更详细的信息请参考帮助手册。
本节课要重点讨论的是:即使父进程在业务逻辑上不关心子进程的终止状态,也需要使用 wait 类系统调用的底层原因。
这其中的要点在于:在 Linux 的内核实现中,允许父进程在子进程创建之后的任意时刻用 wait() 系列系统调用来确定子进程的状态。
也就是说,如果子进程在父进程调用 wait() 之前就终止了,内核需要保留该子进程的终止状态和资源使用等数据,直到父进程执行 wait() 把这些数据取走。
在子进程终止到父进程获取退出状态之间的这段时间,这个进程会变成所谓的僵尸状态,在该状态下,任何信号都无法结束它。如果系统中存在大量此类僵尸进程,势必会占用大量内核资源,甚至会导致新进程创建失败。
如果父进程也终止,那么 init 进程会接管这些僵尸进程并自动调用 wait ,从而把它们从系统中移除。但是对于长期运行的服务器程序,这一定不是开发者希望看到的结果。所以,父进程一定要仔细维护好它创建的所有子进程的状态,防止僵尸进程的产生。
正常终止一个进程可以用 _exit 系统调用来实现,原型为:
void _exit(int status);
其中的 status 会返回 wait() 类的系统调用。进程退出时会清理掉该进程占用的所有系统资源,包括关闭打开的文件描述符、释放持有的文件锁和内存锁、取消内存映射等,还会给一些子进程发送信号(后面课程再详细展开)。该系统调用一定会成功,永远不会返回。
在退出之前,还希望做一些个性化的清理操作,可以使用库函数 exit() 。函数原型为:
void exit(int status);
这个库函数先调用退出处理程序,然后再利用 status 参数调用 _exit() 系统调用。这里的退出处理程序可以通过 atexit() 或 on_exit() 函数注册。其中 atexit() 只能注册返回值和参数都为空的回调函数,而 on_exit() 可以注册带参数的回调函数。退出处理函数的执行顺序与注册顺序相反。它们的函数原型如下所示:
int atexit(void (*func)(void));int on_exit(void (*func)(int, void *), void *arg);
通常情况下,个性化的退出处理函数只会在主进程中执行一次,所以 exit() 函数一般在主进程中使用,而在子进程中只使用 _exit() 系统调用结束当前进程。
本文深入探究了 Linux 进程在用户空间的一些内部细节,包括逻辑内存排布、进程创建和变身的内部细节、进程状态监控的目的和接口,以及终止进程的正确姿势等。对这些底层实现细节的充分理解,能帮助读者更好地理解各个系统调用的行为表现,并根据具体的应用需求选择正确、合适的实现方案。
为了让订阅课程的读者更快更好地掌握课程的重要知识点,我们为每个课程配备了课程学习答疑群服务,邀请作者定期答疑,尽可能保障大家学习效果。同时帮助大家克服学习拖延问题!
购买课程后,一定要添加小助手伽利略微信 GitChatty6(也可扫描下面二维码),备注:Linux 系统编程 ,并将支付截图发给她,小助手会拉你进课程学习群。
参考资料
GitHub 源代码网址:
https://github.com/boswelyu/GitChatLesson-LinuxDevInDepth/tree/master/Lesson4
阅读全文: http://gitbook.cn/gitchat/column/5bfbbe9b7d496f13396961de