书名:Linux/UNIX系统编程手册(上、下册)
ISBN:978-7-115-32867-0
本书由人民邮电出版社发行数字版。版权所有,侵权必究。
您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。
我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。
如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。
• 著 [德] Michael Kerrisk
译 孙 剑 许从年 董 健 孙余强 郭光伟 陈 舸
责任编辑 傅道坤
• 人民邮电出版社出版发行 北京市丰台区成寿寺路11号
邮编 100164 电子邮件 315@ptpress.com.cn
网址 http://www.ptpress.com.cn
• 读者服务热线:(010)81055410
反盗版热线:(010)81055315
Copyright © 2010 by Michael Kerrisk. Title of English-language original: The Linux Programming Interface, ISBN 978-1-59327-220-3, published by No Starch Press. Simplified Chinese-language edition copyright © 2013 by Posts and Telecom Press. All rights reserved.
本书中文简体字版由美国No Starch出版社授权人民邮电出版社出版。未经出版者书面许可,对本书任何部分不得以任何方式复制或抄袭。
版权所有,侵权必究。
本书是介绍Linux与UNIX编程接口的权威著作。Linux编程资深专家Michael Kerrisk 在书中详细描述了Linux/UNIX系统编程所涉及的系统调用和库函数,并辅之以全面而清晰的代码示例。本书涵盖了逾500个系统调用及库函数,并给出逾200个程序示例,另含88张表格和115幅示意图。
本书总共分为64章,主要讲解了高效读写文件,对信号、时钟和定时器的运用,创建进程、执行程序,编写安全的应用程序,运用POSIX线程技术编写多线程程序,创建和使用共享库,运用管道、消息队列、共享内存和信号量技术来进行进程间通信,以及运用套接字API编写网络应用等内容。
本书在汇聚大批 Linux专有特性(epoll、inotify、/proc)的同时,还特意强化了对UNIX标准(POSIX、SUS)的论述,彻底达到了“鱼与熊掌,二者得兼”的效果,这也堪称本书的最大亮点。
本书布局合理,论述清晰,说理透彻,尤其是作者对示例代码的构思巧妙,独具匠心,仔细研读定会受益良多。本书适合从事Linux/UNIX系统开发、运维工作的技术人员阅读,同时也可作为高校计算机专业学生的参考研习资料。
谨将本书献给Cecilia,你照亮了我的世界!
编写Linux软件时如果只能选择一本参考书,则非本书莫属。
——MARTIN LANDERS,Google公司软件工程师
本书描述精到,示例周详,涵盖了Linux底层API编程的详尽内容及个中细微之处——无论读者水平如何,都能从本书中受益。
——MEL GORMAN,Understanding the Linux Virtual Memory Manager作者
Michael Kerrisk的这本Linux编程巨著,不但论及Linux编程、其与各种标准之间的联系,而且还就作者所知,重点介绍了已获修正的Linux内核bug以及改进颇多的Linux手册页。凭此三点,足可让Linux编程更易上手。本书对各项主题的深入探讨使其成为必备的参考书籍——无论读者在Linux编程方面造诣如何。
——ANDREAS JAEGER,NOVELL公司OPENSUSE项目经理
Michael用他坚忍不拔的毅力为Linux程序员奉献了这本论述严谨、表述清晰、简洁的权威参考书。虽然本书针对Linux程序员而著,但对任何在UNIX/POSIX环境中编程的程序员来说都极具价值。
——DAVID BUTENHOF,Programming with POSIX Threads作者、POSIX /UNIX标准撰写者
本书在重点关注Linux系统的同时,对于UNIX系统和网络编程也阐述透彻,浅显易懂。无论是初涉UNIX编程的新丁,还是编程经验丰富的UNIX老手(想要了解大行其道的GNU/Linux系统有何新意),我都向他们力荐此书。
——FERNANDO GONT,网络安全研究员、IETF参与者、IETF RFC作者
本书以百科全书般的叙述风格对Linux接口编程做了既深且广的覆盖,还提供了大量教科书风格的编程示例和练习。本书所包含的各项主题——从原理到可以实际运行的代码——都已描述清晰且易于理解。本书正是专业人士、学生以及教育工作者所期盼的Linux/UNIX参考书。
——ANTHONY ROBINS ,奥塔哥大学计算机科学副教授
无论从精确性、质量,还是详细程度来说,本书都令我印象深刻。身为Linux系统调用的行家,Michael Kerrisk与我们分享了他对Linux API的认知和理解。
——CHRISTOPHE BLAESS,Programmation système en C sous Linux作者
对于治学严谨的专业Linux/UNIX系统程序员而言,本书实为必备的参考书籍。本书涵盖了所有关键API的使用,同时兼顾Linux和UNIX系统接口,描述清晰,示例具体;除此之外,还强调了遵从诸如SUS和POSIX 1003.1等标准的重要性和益处。
——ANDREW JOSEY,The Open Group 标准部总监、POSIX 1003.1工作组主席
由手册页的维护者亲自操刀,以系统程序员视角写出一本百科全书式的Linux系统编程巨著,还有比这更完美的吗?本书全面而又详实。我坚信本书将在我的书架上牢牢占据一席之地。
——BILL GALLMEISTER,POSIX.4 Programmer’s Guide: Programming for the Real World作者
本书是最新最全的Linux/UNIX系统编程参考书。无论读者是Linux系统编程新兵,还是关注Linux编程和程序移植性的UNIX系统编程老将,又或者只是在寻找一本Linux编程接口方面的优秀参考书的读者,Michael Kerrisk的这本大作都笃定是其案头良伴。
——LOÏC DOMAIGNÉ,CORPULS.COM首席软件架构师(嵌入式)
本书将描述Linux编程接口:由UNIX操作系统的开源实现——Linux所提供的系统调用、库函数以及其他底层接口。运行于Linux之上的每一个程序都会直接或间接地使用这些接口。这些接口允许应用程序去执行诸多任务:文件I/O、创建/删除文件和目录、创建新进程、执行程序、设置定时器、在同一台计算机上发起进程或线程间通信,以及为联网计算机间的进程建立通信等等。有时,人们也将这一系列的底层接口称为系统编程接口。
尽管本书着眼于Linux,但对于标准和可移植性问题也倍加关注。对于Linux所特有的技术细节,以及已由POSIX和SUS标准化的UNIX普遍特性,本书会在论述中清晰地加以区分。因此,本书也提供了对UNIX/POSIX编程接口的全面描述。对于那些在其他UNIX系统环境中编程,或者编写跨平台可移植应用的程序员来说,本书同样具有实用价值。
本书主要针对以下读者:
作者假定读者之前有些许编程经验,但不必是在系统编程领域。此外,作者还假定读者具备阅读C语言源码的能力,并了解如何使用shell和UNIX或Linux的常用命令。对于不熟悉UNIX和Linux的读者来说,阅读第2章中面向程序员对UNIX和Linux系统基本概念所做的回顾会有所帮助。
提示:[Kernighan & Ritchie, 1988]是最具权威性的C语言参考书籍。[Harbison& Steele, 2002]一书对C语言的介绍则更为详细,并涵盖了由C99标准所带来的变化。[van der Linden, 1994]也是一本不错的C语言书籍,寓教于乐。[Peek et al., 2001]则对UNIX的使用做了简洁而完整的介绍。
贯穿本书,会以这种缩进小字体的文字形式用于旁注,其内容包括基本原理、实现细节、背景信息、史上轶闻以及与正文相关的其他辅助主题。
其他UNIX实现的大多数特性同样见诸于Linux,反之亦然。有鉴于此,本书本可只关注于标准UNIX(即POSIX)的系统编程。编写可移植的应用程序固然是值得追求的目标,但描述Linux对标准UNIX编程接口的扩展也同样重要。Linux的广受欢迎只是原因之一,而有时出于性能方面的考虑,或是需要访问标准UNIX编程接口所不支持的功能时,使用非标准扩展(正因如此,所有UNIX实现都提供有非标准扩展)就显得至为重要,此为原因之二。
综上所述,在构思本书时,作者不但力图使其对在各种UNIX实现中编程的程序员有所帮助,还全面介绍了Linux专有的编程特性,如下所示。
本书主要有以下两方面的用途:
本书各章可分为以下几个部分。
1.背景知识及概念:UNIX、C语言以及Linux的历史回顾,以及对UNIX标准的概述(第1章);以程序员为对象,对Linux和UNIX的概念进行介绍(第2章);Linux和UNIX系统编程的基本概念(第3章)。
2.系统编程接口的基本特性:文件I/O(第4章、第5章),进程(第6章),内存分配(第7章),用户和组(第8章),进程凭证(process credential)(第9章),时间(第10章),系统限制和选项(第11章),以及获取系统和进程信息(第12章)。
3.系统编程接口的高级特性:文件I/O缓冲(第13章),文件系统(第14章),文件属性(第15章),扩展属性(第16章),访问控制列表(第17章),目录和链接(第18章),监控文件事件(第19章),信号(signals)(第20~22章),以及定时器(第23章)。
4.进程、程序及线程:进程的创建、终止,监控子进程,执行程序(第24~28章),以及POSIX线程(第29~33章)。
5.进程及程序的高级主题:进程组、会话以及任务控制(第34章),进程优先级和进程调度(第35章),进程资源(第36章),守护进程(第37章),编写安全的特权程序(第38章),能力(capability)(第39章),登录记账(第40章),以及共享库(第41章和第42章)。
6.进程间通信(IPC):IPC概览(第43章),管道和FIFO(第44章),系统V IPC消息队列、信号量(semaphore)及共享内存(第45~48章),内存映射(第49章),虚拟内存操作(第50章),POSIX消息队列、信号量及共享内存(第51~54章),以及文件锁定(第55章)。
7.套接字和网络编程:使用套接字的IPC和网络编程(第56~61章)。
8.高级I/O主题:终端(第62章),其他I/O模型(第63章),以及伪终端(第64章)。
本书会以简短而完整的程序示例来描述大部分编程接口,以期读者通过命令行方便地体验这些程序,从而了解各种不同系统调用和库函数的运作方式。因此,本书包含了大量代码示例——约15 000行C语言代码和shell会话记录。
虽然阅读并执行上述示例代码是学习Linux编程接口的一个不错的起点,但巩固本书所述概念最为有效的方式还是动手编写代码——无论是修改示例程序以验证自己的编程思路,还是编写全新的程序。
书中所有源代码均可从本书网站上下载。网站所发布的源码中还包含了不少未见诸于本书的其他程序。这些程序的用途和详细信息在源码注释中均有描述。源码中还提供了Makefile,用来构建相应的程序,附带的README文件则提供了相应程序的具体细节。
在GNU Affero通用公共许可证(Affero GPL)版本3条款的约束下,可自行重新发布和修改本书源码,源码中也提供了一份GNU Affero GPL版本3的文件副本。
本书各章大都会在结尾处附有一组习题,其中一部分会利用书中的程序示例进行各种试验,另一些则与本章所讨论的概念相关,而其他习题则是引导读者亲自动手编程,意在巩固读者对所学内容的理解。附录F会有选择地给出部分习题的答案。
本书自始至终都对可移植性问题予以了特殊关注。对相关标准的引用在书中会反复出现,尤其是POSIX.1-2001和SUSv3的联合标准①。此外,本书还包括了该标准最新版本(POSIX.1- 2008与SUSv4联合标准)的变更细节。(由于SUSv3较之于之前的版本做了大范围的修订,并且在写作本书之际,SUSv3依然是影响最为广泛的UNIX标准,故而本书对标准的讨论一般都以SUSv3为框架,并会注明其与SUSv4之间的差别。然而,对读者来说,除非另有说明,与SUSv3规范有关的论述同样适用于SUSv4。)
对于那些尚未标准化的特性,本书会列出与其他 UNIX 实现间的差异范围。此外,本书也会强调那些独具Linux实现特色的主要特性,以及Linux和其他UNIX实现之间在系统调用和库函数方面的细微差别。任何特性,凡未注明为Linux所专有,读者通常可将其视为大部分或所有UNIX实现的标准特性。
书中的编程示例(除了注明为Linux所专有的特性)大多已在Solaris、FreeBSD、Mac OS X、Tru64 UNIX以及HP-UX中的所有或部分系统上进行了测试。为了改进针对其中某些系统的可移植性,本书的Web站点还为特定编程示例提供了其他版本,此类代码就不再于本书中列出。
本书主要着眼于Linux 2.6.x,撰写本书之际,这一内核版本的使用也最为广泛。本书同样涵盖了Linux 2.4内核的详细信息,也会适时指明Linux 2.4和2.6内核间的特性差异。凡是见诸于Linux 2.6.x系列中的新特性,作者均会标出其(首度)出现的确切内核版本号(例如,2.6.34)。
至于C语言函数库,本书会重点关注GNU C语言库(glibc)版本2。本书也会适时指出glibc 2.x版本之间所存在的差异。
本书付梓之际,Linux内核版本2.6.35刚刚问世,不久又发布了glibc版本2.12。本书目前的论述涵盖了以上两种软件的这两个版本。本书出版后,Linux和glibc接口所发生的变化会在本书的Web站点上公布。
虽然本书的程序示例都是以C语言编写而成的,但读者也能使用其他编程语言来调用本书所描述的编程接口。这些语言既包括编译语言,例如:C++、Pascal、Modula、Ada、FORTRAN和D语言,也包括脚本语言,例如:Perl、Python和Ruby(如要使用Java,则需另辟蹊径,可参阅[Rochkind, 2004])。这需要运用不同的技术以获取必要的常量定义和函数申明(C++除外),而按C语言链接惯例所约定的方式来传递函数参数可能也需要额外的工作投入。虽然实现起来有差异,但基本原理却都相同,即便读者在使用其他语言进行编程,本书所含信息对他们也同样适用。
本人于1987年开始使用UNIX和C语言。当时,作者连续几个礼拜都泡在一台HP Bobcat工作站旁,陪伴我的只有Marc Rochkind所著Advanced UNIX Programming(第1版)一书,以及一本最终被翻得卷了边的C shell手册的印刷本。投入时间阅读文档(如果有的话),并编写一些小型的(规模可逐渐变大)测试程序进行试验,直至自己对软件的理解感到信心满满——这是作者当时所采用的编程学习方法,并一直沿用至今——作者也向任何试水新型软件技术的人们推荐这一做法。依作者拙见,从长远来看,这种自学方法能够大大节约时间。本书所载的许多编程示例正是在这一学习方法的激励之下设计而成。
作者的主要身份是软件工程师和设计师。然而,作者同样好为人师,并在学术或商业领域有过数年的教学经验。作者还开设过多门为期一周的 UNIX 系统编程课程,这一经验对本书的写作也颇有影响。
作者使用Linux的时间大约只有与UNIX打交道的时间一半长,在这段时间里,作者的兴趣也逐渐集中在了内核和用户空间的“分水岭”——Linux编程接口上。这一兴趣也使作者投身于一系列紧密相联的活动中。作者会时不时地对POSIX/SUS标准提出自己的意见并提供BUG报告;对新加入Linux内核中的用户空间接口进行测试和设计评审(还能帮助发现并修复那些接口中的诸多代码和设计缺陷);作者还经常在关于编程接口及其文档的主题会议上发言,并受邀多次出席Linux内核开发者年度峰会。将上述所有活动串接在一起的主线是作者对Linux领域最突出的贡献:作者为Linux man-pages项目(http://www.kernel.org/doc/man-pages/)所做的工作。
Linux手册页中的第2、3、4、5以及7部分都属于man-pages项目。这几部分也是手册页中描述编程接口的内容,这些编程接口由Linux内核及GNU C语言库提供,本书所要介绍的正是这方面的内容。作者参与man-pages项目已逾十载。自2004年起,作者成为了该项目的主要维护人,所承担的任务大致包括:撰写文档、阅读内核和C语言库源码,以及通过编程来验证文档细节(通过为接口撰写文档来发现相关接口中的BUG,效果颇为不俗)。此外,作者对man-pages项目的贡献也最多——在约900页的手册页中,作者独自编写了其中的140页,并与他人合著了另外的125页。因此,在购买本书之前,读者想必已经阅读过本人的工作成果了。作者希望这些成果能对读者有所帮助,希望本书更是如此。
没有一干人等的支持,本书的质量绝不会如此之高。我要向他们致以最诚挚的谢意。
来自世界各地的多位技术审稿人都参与了本书初稿的阅读,找出错误,指出含糊不清的解释,对措辞、插图以及习题提出建议,测试程序,发现不为作者所知的Linux和其他UNIX实现间的行为差异,并不时为作者打气助威。在本书中,作者将许多审稿人无私奉献的真知灼见一并收纳,实则作者的知识并非如此渊博。当然,书中的任何错误都是作者一人之过。
无论以下技术审稿人(按姓氏字母排序)对本书手稿的审校巨细与否,篇幅多少,作者都要向他们致以由衷的谢意。
还有许多其他技术审稿人也审校了本书的不同内容,并提出了诸多宝贵意见。在此,作者向以下一干技术审稿人表示感谢(以姓氏字母顺序排列):George Anzinger(MontaVista Software)、Stefan Becher、Krzysztof Benedyczak、Daniel Brahneborg、Andries Brouwer、Annabel Church、Dragan Cvetkovic、Floyd L. Davidson、Stuart Davidson(Hewlett-Packard Consulting)、Kasper Dupont、Peter Fellinger(jambit GmbH)、Mel Gorman(IBM)、Niels Göllesch、Claus Gratzl、Serge Hallyn(IBM)、Markus Hartinger(jambit GmbH)、Richard Henderson(Red Hat)、Andrew Josey(The Open Group)、Dan Kegel(Google)、Davide Libenzi、Robert Love(Google)、H.J. Lu(Intel Corporation)、Paul Marshall、Chris Mason、Michael Matz(SUSE)、Trond Myklebust、James Peach、Mark Phillips(Automated Test Systems)、Nick Piggin(Novell SUSE实验室)、Kay Johannes Potthoff、Florian Rampp、Stephen Rothwell(IBM Linux技术中心)、Markus Schwaiger、Stephen Tweedie(Red Hat)、Britta Vargas、Chris Wright、Michal Wronski以及 Umberto Zamuner。
除了技术审稿人之外,作者还得到了各界人士及组织在其他方面的帮助。
我要感谢以下人等为我解答技术难题,他们是:Jan Kara、Dave Kleikamp和Jon Snader。我要感谢Claus Gratzl和Paul Marshall在系统管理方面对我的帮助。
我要感谢Linux基金会(LF)。2008年间,LF资助我作为一名全职研究人员参与man-pages项目,并从事Linux编程接口的测试和设计评审工作。虽然LF全职研究员的身份不能为本书的写作提供直接的资金支持,但却使作者得以养家糊口,这一助力本意在于令我全身心投入对Linux编程接口的测试以及对文档的编纂工作,却也惠及作者的“私活”。抛开公事不谈,我要感谢Jim Zemlin——我在LF的“接口”人,还要感谢LF技术咨询委员会的一干专家,感谢他们对我的聘任。
25年前,在我为第一个学位拼搏之时,Robert Biddle激起了我对UNIX、C以及Ratfor的兴趣,谢谢你,老兄。虽然以下诸君与本书并无直接干系,但当我在新西兰坎特伯雷大学攻读第二学位时,他们就鼓励我在写作道路上坚持下去,在此,我要向他们表示感谢,他们是Michael Howard、Jonathan Mane-Wheoki、Ken Strongman、Garth Fletcher、Jim Pollard,以及Brian Haig。
由Richard Stevens所著的几部关于UNIX编程和TCP/IP方面的杰作,数年来一直被我辈程序员奉为圭臬,只可惜先贤已逝。凡是读过上述书籍的读者势必会注意到,本书与Richard Stevens的那几本巨著看起来有些相似。这并非偶然。在构思本书时,作者曾从较为宏观的角度就书籍设计反复斟酌,可最终发现Richard Stevens所采用的方法才是正解,正因如此,本书采用了与其相同的展示方式。
感谢下列人士和组织为我提供UNIX系统,使我得以运行测试程序,并验证其他UNIX实现的细节,感谢Anthony Robins和Cathy Chandra在新西兰Otago大学所提供的多种UNIX测试系统,感谢Martin Landers、Ralf Ebner和Klaus Tilk在德国慕尼黑技术大学(Technische Universität)所提供的多种UNIX测试系统,感谢HP公司在Internet上免费开放他们的testdrive系统,感谢Paul de Weerd使我得以访问OpenBSD系统。
要衷心感谢两家慕尼黑公司及其老板,这两家公司除了为我提供了工作机会(还是弹性工作制)和热情的同事,还格外开恩,允许我在写作本书时使用他们的办公室。感谢exolution有限公司的Thomas Kahabka和Thomas Gmelch,特别要感谢jambit 有限公司的Peter Fellinger和Markus Hartinger。
感谢下列人士对我提供的各种帮助,他们是Dan Randow、Karen Korrel、Claudio Scalmazzi、Michael Schüpbach和Liz Wright。感谢Rob Suisted和Lynley Cook为封面和封底所提供的照片。
感谢下列人士以不同方式给作者以鼓励和支持,他们是Deborah Church、Doris Church和Annie Currie。
感谢No Starch出版社大队人马为这一庞大创作项目所提供的各种帮助。Bill Pollock从项目之初就一直秉持直言不讳的风格,始终对本书的完成充满信心,并耐心地关注着项目的进展,我要对他表示感谢。感谢本书最初的责任编辑Megan Dunchak。感谢本书的文字编辑Marilyn Smith,无论我如何殚精竭虑以求文字的清晰与一致,此君总能从鸡蛋里挑出骨头。本书的版面和设计由Riley Hoffman全面负责,在“上了同一条船”后又挑起了制作编辑的重担。Riley总是不厌其烦地满足我的请求,以求本书的排版无误——最终结果堪称完美。谢谢你。
现在,我才体味出下面这句老话的真正含义:一人写作,全家受累。感谢Britta和Cecilia对我的支持,感谢你们能容忍我因写作本书而长时间地不着家。
承蒙IEEE(美国电气电子工程师学会)和The Open Group惠允,本书得以引用IEEE Std 1003.1,2004版以及The Open Group基础规范第6号(Issue 6)中POSIX(可移植性操作系统接口)——信息技术标准的部分文字。可通过http://www.unix.org/version3/online.html在线查阅规范的完整版本。
读者可在http://man7.org/tlpi上找到更多有关本书的信息,包括本书的勘误表和程序示例源码。
欢迎读者提供BUG报告、对代码的改进建议,以及为进一步提高代码可移植性而提出的修订意见。同样欢迎读者提供针对本书内容的缺陷报告和改进叙述方式的一般性建议。当前的勘误列表可参见http://man7.org/tlpi/errata/。由于Linux编程接口变化无常、且变更有时极为频繁,仅凭作者一己之力很难“与时俱进”,因此读者就全新或已变更的Linux编程接口特性所提供的反馈信息,作者也将乐于收到,并会纳入本书的下一版中。
Michael Timothy Kerrisk
于德国慕尼黑和新西兰克赖斯特彻奇
2010年8月
mtk@man7.org
①译者注:大致可视为一套标准的两种称谓。
Linux是UNIX操作系统家族中的一员。就计算机的发展而言,UNIX历史悠久。本章的第一部分会简要介绍UNIX的历史——以对UNIX系统和C编程语言起源的回顾拉开序幕,接着会述及成就今日Linux系统的两大关键因素:GNU项目和Linux内核的开发。
UNIX系统最引人关注的特征之一,是其开发不受控于某一厂商或组织。相反,许多团体——既有商业团体,也有非商业团体——都曾为UNIX的演进做出过贡献。这一渊源使UNIX集多种开创性的特性于一身,但同时也带来了负面影响——随着时间的推移,UNIX的实现渐趋分裂。因此,要编写出能够运行于所有UNIX实现之上的应用程序愈发困难。这又导致了人们对UNIX实现的标准化呼声越来越高,本章的第二部分将讨论这一问题。
对UNIX的定义通常有两种。其一是指通过SUS所规范的官方一致性测试,且由OPEN GROUP(UNIX商标的持有者)正式授权冠以“UNIX”的操作系统。在写作本书之际,尚无开源的UNIX实现(比如,Linux和FreeBSD)获得了“UNIX”冠名。
在第二种定义中,UNIX是指那种运作方式类似于经典UNIX系统(比如,最初的Bell实验室UNIX系统,及其后来的主要分支System V和BSD)的操作系统。根据这一定义,一般将Linux视为UNIX系统(如同现代BSD系统一样)。尽管本书会密切关注SUS,但也会遵循对UNIX的第二种定义,因此诸如“Linux,像其他UNIX实现一样……”这样的说法,会在书中频繁出现。
1969年,在AT&T电话公司下辖的bell实验室中,Ken Thompson开发出了首个UNIX实现。该实现是使用Digital PDP-7小型机的汇编语言开发而成的。其名称UNIX是“MULTICS(多信息及计算服务,Multiplexed Information and Computing Service)”一词的双关语,而MULTICS之名则出自一个早期的操作系统开发项目,该项目由AT&T、MIT(麻省理工学院)以及通用电器公司联合开发。(因为未能开发出一款经济实用的操作系统,该项目首战失利。沮丧之余,AT&T随即退出这一项目中。)Thompson设计新操作系统的某些灵感正源于MULTICS,其中包括:树形结构的文件系统、设立单独的程序用于解释命令(shell),以及将文件作为无结构字节流看待的概念。
1970年,AT&T的工程师们又在刚购进的Digital PDP-11小型机上,以汇编语言重写了UNIX,当时,Digital PDP-11算得上是最新颖、功能也最为强劲的计算机了。从大多数UNIX实现(包括Linux)沿用至今的各种名称上,仍能发现这一PDP-11实现所残留的历史遗迹。
未过多久,Dennis Ritchie(Thompson在bell实验室的同事,UNIX开发的早期合作者)设计并实现出了C编程语言。这里有一个演变过程:C语言传承自早期的解释型语言——B语言;B语言最初由Thompson实现,但其所包含的许多理念却来自于更早期的编程语言——BCPL。到了1973年,C语言步入了成熟期,人们能够使用这一新语言重写几乎整个UNIX内核。UNIX因此也一变而为最早以高级语言开发而成的操作系统之一,这也促成了UNIX系统后续向其他硬件架构的移植。
从C语言的起源不难看出为什么C语言及其“后裔”C++是当今使用最为广泛的系统编程语言。早期流行的编程语言其设计初衷并不在于此,例如:FORTRAN语言意在帮助工程师和科研工作者们进行数学计算,COBOL语言则是在商业系统中用来处理面向记录的数据流。C语言的出现,填补了当时系统编程方面的语言空白。与FORTRAN和COBOL不同(这两种编程语言均由大型组织设计开发),C语言的设计理念和设计需求出自于几位程序员的构思,他们的目标很单纯:为实现UNIX内核及其相关软件而开发一种高层语言。像UNIX操作系统本身一样,C语言由专业程序员设计而为己用。其最终结果堪称完美:C语言的设计前后连贯,且支持模块化设计,成为短小精干、高效实用、功能强大的编程语言。
1969~1979年间,UNIX历经了多次发布,也称为版本(edition)。实质上,这些发布是AT&T对UNIX进行演进开发时的一系列版本快照。[Salus,1994]记录了UNIX前六版的发布日期如下。
在此期间,UNIX使用范围从AT&T自内而外逐步扩展,声名也随之远播。读者甚众的《ACM通信》杂志刊载了一篇关于UNIX的论文([Ritchie & Thompson, 1974]),这对UNIX知名度的提升功莫大焉。
当时,在美国政府的授权下,AT&T垄断着全美电信市场。AT&T与美国政府达成的协议条款禁止AT&T涉足软件销售行业——这意味着,AT&T不能将UNIX作为产品销售。相反,从1974年的UNIX第五版开始,AT&T准许高校在支付象征性的发布费用后使用UNIX系统——这一现象尤以第六版为烈。UNIX系统的高校发布版包括了相关文档及内核源码(当时,内核源码约为10 000行左右)。
AT&T对高校发布的UNIX极大促进了这一操作系统的普及和使用。时至1977年,UNIX已经在约 500 个站点中运行,其中包括了全美及其他国家的 125 所大学。当时的商业操作系统非常昂贵,UNIX则为高校提供了一种交互式多用户操作系统,可谓物美价廉。此外,各校的计算机系还籍此获得了“鲜活”的操作系统源码,可以对源码进行修改,还可供学生们学习、实验之用。一些以UNIX知识为武装的学生后来成为UNIX“传教士”。另外一些学生则组建或加盟了大量新兴公司,其业务主要是销售廉价的计算机工作站,而运行于其上的正是易于移植的UNIX操作系统。
发布于1979年1月的UNIX第七版改善了系统的可靠性,配备了增强型的文件系统。该版本还附带了不少新的工具软件,其中包括:awk、make、sed、tar、uucp、Bourne shell以及FORTRAN 77编译器。第七版UNIX发布的重要意义还在于,从该版本起,UNIX分裂为了两大分支:BSD和System V。接下来会简要描述二者的由来。
受母校加州大学伯克利分校之邀,Thompson于1975/1976学年曾担任该校的客座教授。在此期间,他与研究生们一起为UNIX开发了许多新特性。(他的学生之一,Bill Joy,后来与人共同组建了SUN微系统公司——一家最早涉足UNIX工作站市场的公司。)光阴荏苒,许多UNIX的新工具和新特性又陆续在伯克利分校问世,这包括:C shell、vi编辑器、一种改进型的文件系统(伯克利快速文件系统)、sendmail、Pascal语言编译器,以及用于新型Digital VAX架构的虚拟内存管理机制。
这一命名为BSD(伯克利软件发布,Berkeley Software Distribution)的UNIX版本(包括源码在内)分发颇广。1979年12月,诞生了首个完整的UNIX发布版3BSD。(之前发布的Berkeley-BSD和2BSD并非完整的UNIX发布版,仅含由伯克利分校开发的新工具。)
1983年,加州大学伯克利分校的计算机系统研究组(Computer Systems Research Group)发布了4.2BSD。该版本的发布意义深远,因为其包含了完整的TCP/IP实现,其中包括套接字应用编程接口(API)以及各种网络工具。4.2BSD及其前身4.1BSD在世界上多所大学开始广为流传。以这两者为基础,还形成了SunOS操作系统(首发于1983年)——这一由SUN公司销售的UNIX变种。其他重要的BSD版本还有发布于1986年的4.3BSD,以及发布于1993年的最终版本4.4BSD。
首批UNIX向非PDP-11硬件机型的移植发生在1977年和1978年,当时,Dennis Ritchie和Steve Johnson将UNIX移植到了Interdata 8/32上,与此同时,澳大利亚Wollongong大学的Richard Miller也将其移植到了Interdata 7/32上。伯克利分校针对Digital Vax架构的移植——也称为32V,则基于John Reiser和Tom Lodon较早前(1978年)的工作成果。该移植本质上与PDP-11上的UNIX第七版相同,只是支持的地址空间更大、数据类型更宽罢了。
与此同时,美国的反托拉斯法案强制对 AT&T 进行拆分(于20世纪70年代中期开始立案,到1982年AT&T正式解体)。随着其在电话系统市场垄断地位的丧失,AT&T 也因而获准销售UNIX。这也催生了1981年System III(3)的发布。System III由AT&T所属的UNIX支撑团队(UNIX Support Group,USG)研发,该团队雇佣了数以百计的研发人员来从事UNIX系统的增强以及应用开发(尤其针对文档预备软件包和软件开发工具)。1983年,System V的首个发布版又接踵而至,在经过一系列发布后,USG最终于1989年推出了System V Release 4(SVR4),此时的System V纳入了BSD的诸多特性,包含联网能力。AT&T将System V授权给不同厂商,这些厂商又将其作为自身UNIX实现的基础。
因此,除了遍布于学术界的各种BSD发布版外,到20世纪80年代末,商业性质的UNIX实现在各种硬件架构上都有了广泛应用。这包括:SUN公司的SunOS,以及后来的Solaris;Digital公司的Ultrix和OSF/1(在历经一系列更名和收购后,现称为HP Tru64 UNIX);IBM公司的AIX;HP公司的HP-UX;NeXT公司的NeXTStep;在Apple Macintosh机上的A/UX;以及Microsoft和SCO公司联合为Intel x86-32架构开发的XENIX。(贯穿本书,我们将x86-32架构上的Linux实现称为Linux/x86-32。)这一局面与当时典型的专有硬件搭配专有操作系统的模式形成了鲜明对照,那时,每个厂商只生产一种或至多几种专有的计算机芯片架构,然后再销售运行于该硬件架构之上的专有操作系统。大多数厂商系统的这种专有性,意味着消费者只能在一棵树上“吊死”。转换到另一专有操作系统和硬件平台,其代价十分高昂,不但需要移植现有应用,还需要对操作人员进行重新培训。从商业角度来看,考虑到上述因素,加之各厂商纷纷推出了廉价的单用户UNIX工作站,具备可移植性的UNIX系统魅力逐渐开始“凸显”。
术语Linux通常用来指代完整的类UNIX(UNIX-like)操作系统,Linux内核只是其中的一部分。这么定义多少有些措辞不当,因为一般商业Linux发布版中所含的诸多关键组件实际上发源于另一项目,早在Linux问世前几年就已经启动了。
1984年,Richard Stallman之前一直供职于MIT的一位天赋异禀的程序员,开始着手创建一个“自由的(free)”UNIX实现。Stallman的观点属于道德层面,而对“free”一词的定义则属于法律范畴而非经济范畴(请参见http://www.gnu.org/philosophy/free-sw.html)。然而,Stallman所描述的这一法律意义上的“自由(freedom)”却蕴含着言外之意:应可免费或以低价获得诸如操作系统之类的软件。
对于那些在专有操作系统上强加限制条款的计算机厂商来说,Stallman 的这一举动无疑妨害了他们。所谓的限制条款是指:在一般情况下,计算机软件的消费者不但无权阅读自己所购软件的源码,而且还不能复制、更改及重新发行所购软件。Stallman指出,在这种体制之下,只会造成程序员之间勾心斗角、敝帚自珍的局面,无法实现工作协同和成果共享。
与之针锋相对,为开发出一套完整而又可自由获取,包含内核以及所有相关软件包的类UNIX系统,Stallman发起了GNU项目(“GNU’s not UNIX”的递归缩写形式),并积极邀请有志之士加盟。1985年,Stallman创立了非盈利机构——自由软件基金会(FSF),以支持GNU项目和广义意义上的自由软件开发。
GNU项目启动之时,BSD还不具备Stallman所指的那种“free”属性。使用BSD不但仍需获得AT&T的许可,而且用户不得随意修改并重新发布BSD中AT&T拥有产权的代码部分。
GNU项目的重要成果之一是制定了GNU GPL(通用公共许可协议),这也是Stallman倡导的自由(free)软件概念在法律上的体现。Linux发布版中的大多数软件,包括Linux内核,都是以GPL或与之类似的许可协议发布的。以GPL许可协议发布的软件不但必须开放源码,而且应能在GPL条款的约束下自由对其进行重新发布。可以不受限制的修改以GPL许可协议发布的软件,但任何经修改后发布的软件仍需遵守GPL条款。若经过修改的软件以二进制(可执行)形式发布,那么软件的修改者必需满足软件使用者的以下要求:以不高于发行成本的价格,获得修改后的软件源码。GPL的第一版发布于1989年。当前的许可协议版本为2007年发布的第三版。此许可协议的第二版于1991年发布,至今仍在广泛使用,Linux内核就是以该版许可协议发布的。(对各种自由软件许可协议的讨论可见诸于[St. Laurent, 2004]和[Rosen, 2005]。)
最初,GNU项目未能开发出能够有效运作的UNIX内核,但却开发了大量其他程序。由于这些程序全都针对类UNIX系统而设计,因此(理论上)均有可能在现有的UNIX实现上运行(实际情况也的确如此),更有甚者,有时还被移植到了其他操作系统上。Emacs文本编辑器、GCC(原名为GNU C编译器,现更名为GNU编译器集合,集C、C++,以及其他编程语言的编译器于一身)、bash shell以及glibc(GNU C语言库)便是GNU项目结出的硕果。
到了20世纪90年代早期,GNU项目已经开发出了一套几乎完整的操作系统,除了还缺少其中最重要的一环:能够有效运转的UNIX内核。于是,GNU项目以Mach微内核为基础,发起了一项雄心勃勃的内核设计计划,史称GNU/HURD计划。然而,时至今日,HURD的发布还遥遥无期(写作本书之际,HURD的研发尚在进行中,该内核目前只能运行于x86-32架构之上)。
在构成通常所说的“Linux系统”的程序代码中,由于有相当一部分都源自GNU项目,因此Stallman更愿意用“GNU/Linux”一词来称呼整个系统。这一称谓问题(Linux Vs. GNU/Linux))也在自由软件社区中引发了一些口舌之争。因为本书主要关注Linux内核的API,故而通常会采用术语“Linux”。
万事具备,独缺内核。只要再拥有一个能够有效运作的内核,就能使GNU项目开发出的UNIX系统“功德圆满”。
1991年,Linus Torvalds,一位芬兰赫尔辛基大学的学生,在外界的激励下为自己的Intel 80386 PC开发了一个操作系统。在一门学习课程中,Torvalds开始接触Minix——由荷兰大学教授Andrew Tanenbaum于20世纪80年代中期开发的一款小型、类UNIX的操作系统内核。Tanenbaum将Minix连同源码完全开放,作为大学操作系统设计课程的教学工具。人们可以在386系统上构建并运行Minix内核。当然,正因为其主要用于教学,Minix在设计上几乎独立于硬件架构,故而也未对386处理器的能力充分加以利用。
因此,为了开发出一个高效而又功能齐备的UNIX内核,Torvalds开始“自力更生”。数月之后,Torvalds开发出一个内核“雏形”,可以编译并运行各种GNU程序。随之,于1991年10月5日,为求得其他程序员的帮助,Torvalds在Usenet新闻组comp.os.minix上就其内核0.02版发表了如下申明,如今已被广为引用。
还在念叨 minix1.1 的好日子——人人都能给自个儿写设备驱动,不用看别人的脸色?手头没有称心的项目?是不是特想有一个操作系统,能依着自个的想法来回折腾,还能长见识?瞧瞧 minix 上面跑的那些玩意吧,是不是挺没劲?不想再为调个酷毙了的程序,一宿一宿熬个没完?真这样,那我可找对人了。一个月前在帖子里就提过,我正在写一个操作系统,在AT-386上面跑,免费的,挺像minix。现在总算到了这份上,凑合能用(当然,这得看您想干吗)。现在,我愿意公布系统的源码,请大家多瞅瞅,多用用。系统版本只是0.02,不过bash、gcc、gnu-make、gnu-sed还有compress等等倒是都跑通了。
为了传承UNIX历史悠久的光荣传统,在为UNIX系统克隆命名时,总以字母“X”结尾,故而,人们最终将这一内核命名为Linux。最初,Linux的使用许可协议要严格得多,但Torvalds很快就将其归于GNU GPL阵营。
Torvalds做到了一呼百应。其他程序员与Torvalds一起加入到Linux的开发行列,添加了很多新特性,诸如:改进型的文件系统、对网络的支持、设备驱动程序以及对多处理器的支持等。到了1994年3月,开发者们发布了Linux 1.0版本。随之,Linux 1.2发布于1995年3月,Linux 2.0发布于1996年6月,Linux 2.2发布于1999年1月,Linux 2.4发布于2001年1月。对内核2.5版本的开发始于2001年11月,并最终于2003年12月发布了Linux内核2.6。
值得一提的是,20世纪90年代初,另一种可以免费获得的UNIX也能在x86-32硬件架构上运行。Bill和Lynne Jolitz将业已成熟的BSD系统移植到32位的x86 cpu上,命名为386/bsd。这项移植工作基于BSD Net/2(发布于1991年6月),即4.3BSD源码的版本之一,该版本中残存的所有AT&T专有源码要么被全部替换,要么予以删除——主要针对6个无法轻易更换的源码文件而言。Jolitzes夫妇将Net/2代码移植到了x86-32硬件架构,重写了缺失的源码,并于1992年2月发布了386/BSD的首个版本(0.0版本)。
在初战告捷后,对386/BSD的开发工作便出于各种原因而停滞不前。面对日渐积压的大量补丁程序,另外两组开发团队相机而动,基于386/BSD分别创建了自己的版本:NetBSD和FreeBSD。前者侧重于对大量硬件平台的可移植性;后者则主要关注性能,并成为如今应用最为广泛的BSD。1993年4月,NetBSD首版(版本号为0.8)发布。FreeBSD的首个CD-ROM版本(版本号为1.0)则发布于1993年12月。1996年,OpenBSD在从NetBSD项目分离出去之后,也发布了最初版本(版本号2.0)。相比较而言,OpenBSD偏重于安全性。2003年中段,在与FreeBSD 4.x分道扬镳之后,一款新型BSD——DragonFly BSD又浮出水面。DragonFly BSD采用的设计方法与FreeBSD 5.x有所不同,能够支持对称多处理器(SMP)架构。
若是不提及20世纪90年代初UNIX System Laboratories(USL,派生自AT&T的子公司,专门从事UNIX的开发和销售)和Berkeley之间的那场官司,那么对BSD的介绍恐怕就算不得完整。1992年初,Berkeley Software Design, Incorporated公司(BSDi,如今隶属于Wind River公司)开始发行受商业支持的BSD UNIX——BSD/OS——以Net/2发布版以及Jolitze夫妇所开发的386/BSD特性为基础。BSDi的发布版包含二进制和源代码,售价995美元,此外,BSDi还建议潜在客户使用其电话号码1-800-ITS-UNIX。
1992年4月,USL对BSDi发起诉讼,诉状称BSDi售出产品中含有USL专有源码及商业机密,要求其停止销售。此外,诉状还指称BSDi的电话号码容易误导消费者,要求BSDi停止使用。这场诉讼愈演愈烈,最终还加入了对加州大学的索赔请求。法院最终驳回了USL几乎所有的诉讼请求,仅对其中的两项请求予以支持。随后,加州大学又针对USL发起发诉,诉称:USL没有为System V中使用的BSD代码支付费用。
这场诉讼悬而未决之际,USL 已被Novell收购,Novell时任CEO——Ray Noorda公开声称:较之于法庭辩论,自己的公司更愿意参与市场竞争。双方最终于1994年1月达成庭外和解。在删除Net/2 release 源码18000个文件中的3个文件,对若干其他文件做出细微改动,并为其他大约70个文件添加USL版权注意事项后,加州大学仍可继续自由发布BSD。1994年6月,经过修改的系统以4.4BSD-Lite之名发布(1995年6月,加州大学发布了最后一版4.4BSD-Lite,版本号为Release 2)。此时,根据和解条款,BSDi、FreeBSD以及NetBSD纷纷以经过修改的4.4BSD-Lite源码替换了各自的Net/2基础源码。据[McKusick et al., 1996]一书记述,尽管这在一定程度上延误了BSD衍生系统的开发,但也有其积极意义。加州大学计算机研究组(Computer Systems Research Group)自Net/2发布后3年的开发成果,被重新同步到上述系统中。
与大多自由软件项目一样,Linux也遵循及早、经常的发布模式,因而对内核的修订会频繁出现(有时甚至是每天都有)。随着Linux用户群的激增,对这一发布模式有所调整,意在降低对现有用户的干扰。具体来说,在Linux1.0版本之后,内核开发者针对每次发布所采用的内核版本编号方案为x.y.z。x表示主版本号,y为附属于主版本号的次版本号,z是从属于次版本号的修订版本号(细微的改进和BUG修复)。
采用这一发布模式,内核的两个版本会一直处于开发之中。一个是用于生产系统的稳定(stable)分支,其次版本号为偶数;另一个是经常变动的开发(development)分支,其次版本号为奇数(当前稳定版次版本号+1)。指导思想是(在实践中并未严格执行)应将所有新特性添加到内核当前的开发分支系列中,而对内核稳定分支系列的修订应严格限定为细微的改进及bug修复。当开发者认为当前的开发分支已宜于发布时,会将该开发分支转换成新的稳定分支,并为其分配一个偶数的次版本号。例如,内核开发分支2.3.z会“进化”为内核稳定分支2.4。
随着2.6内核的发布,内核开发模式再次发生改变。稳定内核版本之间发布间隔过长,因而导致诸多问题和不便,这是内核开发模型改变的主要原因(从Linux 2.4.0到2.6.0的发布历时近3年)。虽然还会就该模型的微调定期开展讨论,但基本细节已经确定如下。
本书后续各章有时会提及API发生特定变化(比如,新增了系统调用或者系统调用发生变化时)的相应内核版本。在2.6.z系列之前,虽然大多数内核变化都见诸于具有奇数版本号的开发分支,但本书通常所指的是那些变化初次出现的稳定内核版本,这是因为大多数应用开发者一般都会使用稳定版的内核,而非开发版本。很多情况下,手册页会注明某一具体特性出现或发生变化时开发版内核的确切版本号。
对2.6.z系列内核所发生的改变,本书会注明确切的内核版本号。当书中言及2.6版本内核的新特性,且版本号又不带“z”这一修订版本号时,意指该特性是在2.5开发版内核中实现,并首度出现于稳定内核版本2.6.0。
写作本书之际,Linux内核2.4的稳定版尚处于维护期,维护者们仍在将关键性的补丁和缺陷修正合并起来,定期发布新的修订版。这使得已安装系统能继续使用2.4内核,而不必非要升级到新的内核系列(有时候,升级起来并不轻松)。
Linux开发之初,主要目标是针对Intel 80386的高效系统实现,而非向其他处理器架构迁移的可移植性。然而,随着Linux的日益普及,针对其他处理器架构的移植版本开始出现,首先就是向Digital Alpha芯片的移植。Linux所支持的硬件架构队伍在持续壮大,其中包括:x86-64、Motorola/IBM PowerPC和PowerPC64、Sun SPARC和SPARC64(UltraSPARC)、MIPS、ARM(Acorn)、IBM zSeries(formerly System/390)、Intel IA-64(Itanium,请参阅[Mosberger & Eranian, 2002])、Hitachi SuperH、HP PA-RISC,以及Motorola 68000。
准确说来,术语Linux只是指由Linus Torvalds和其他人所开发出的内核。可是,也常使用该术语来指代内核外加一大堆其他软件(工具和库)所构成的完整操作系统。Linux草创之际,需要用户自行组装上述所有软件,创建文件系统,在文件系统上正确地安置并配置所有软件。用户不但要具备专业知识,还需为此耗费大量时间。如此一来,这便为Linux发行商们开启了市场,他们创建软件包(发行版),来自动完成大部分安装过程,其中包括了建立文件系统以及安装内核和其他所需软件等。
Linux的发行版最早出现于1992年,包括MCC Interim Linux(英国,曼彻斯特计算机中心)、TAMU(德克萨斯A&M大学)以及SLS(SoftLanding Linux System)。至今健在的商业发行版Slackware诞生于1993年。几乎与此同时,也诞生了非商业的Debian发行版,SUSE和Red Hat紧随其后。时下最流行的Ubuntu发行版问世于2004年。如今,对于那些在自由软件项目中表现活跃的程序员,许多Linux发行公司也会加以雇佣。
20世纪80年代末,可用的UNIX实现层出不穷,由此也带来了种种弊端。有些UNIX实现基于BSD,而另一些则基于System V,还有一些则是对两大“流派”“兼容并蓄”。更有甚者,每个厂商都在自己的UNIX实现中添加了额外特性。其结果是将软件及技术人员在不同UNIX实现间转移就变得异常困难。这一形式有力地推动了C语言和UNIX系统的标准化进程,使得应用程序能够在不同操作系统间很方便地进行移植。接下来,将介绍由此而产生的各种标准。
20世纪80年代初,C语言问世已达10年之久,在大量UNIX系统以及其他操作系统上都有实现。各种C语言的实现之间存在着细微差别,这部分是由于在当时C语言事实上的标准——Kernighan和Ritchie于1978年所著的The C Programming Language一书中(有时,人们将书中所记载的老式C语言语法称为传统C或K&R C),并未就C语言在某些方面的运作方式进行细化。此外,借鉴于1985年面世的C++语言,在不破坏现有程序的前提下,C语言得以进一步丰富和完善,其中最知名的莫过于函数原型、结构赋值、类型限定符(const and volatile)、枚举类型以及void关键字。
上述因素形成了C语言标准化进程的强力推手,ANSI(美国国家标准委员会)C语言标准(X3.159-1989)最终于1989年获批,随之于1990年被ISO(国际标准化组织)所采纳(ISO/IEC 9899:1990)。这份标准在定义C语言语法和语义的同时,还对标准C语言库操作进行了描述,这包括stdio函数、字符串处理函数、数学函数、各种头文件等等。通常将C语言的这一版本称为C89或者(不太常见的)ISO C90,Kernighan和 Ritchie所著的The C Programming Language第2版(1988)对其有完整描述。
1999年,ISO又正式批准了对C语言标准的修订版(ISO/IEC 9899:1999,请见http://www.open-std.org/jtc1/sc22/wg14/www/standards)。通常将这一标准称为C99,其中包括了对C语言及其标准库的一系列修改,诸如,增加了long long和布尔数据类型、C++风格的注释(//)、受限指针以及可变长数组等。写作本书之际,对C语言标准的进一步修订(非正式命名为C1X)仍在进行之中,预计将于2011年正式获批。
C语言标准独立于任何操作系统,换言之,C语言并不依附于UNIX系统。这也意味着仅仅利用标准C语言库编写而成的C语言程序可以移植到支持C语言实现的任何计算机或操作系统上。
回顾历史,过去的ANSI C通常指C89,时至今日,这一用法还时有所见。GCC就是一例,其限定符-ansi意指“支持所有ISO C90程序”。然而,本书会避免这种用法,因为如今该术语的含义有些含糊不清。自从ANSI委员会批准了C99修订版之后,确切说来,现在的ANSI C应该是C99。
术语“POSIX(可移植操作系统Portable Operating System Interface的缩写)”是指在IEEE(电器及电子工程师协会),确切地说是其下属的可移植应用标准委员会(PASC, http://www.pasc.org/)赞助下所开发的一系列标准。PASC标准的目标是提升应用程序在源码级别的可移植性。
POSIX之名来自于Richard Stallman的建议。最后一个字母之所以是“X”是因为大多数UNIX变体之名总以“X”结尾。该标准特别注明,POSIX应发音为“pahz-icks”,类似于“positive”。
本书会关注名为POSIX.1的第一个POSIX标准,以及后续的POSIX.2标准。
POSIX.1于1989年成为IEEE标准,并在稍作修订后于1990年被正式采纳为ISO标准(ISO/ IEC 9945-1:1990)。无法在线获得这一POSIX标准,但能从IEEE(http://www.ieee.org/)购得。
POSIX.1一开始是基于一个更早期的(1984年)非官方标准,由名为/usr/group的UNIX厂商协会制定。
符合POSIX.1标准的操作系统应向程序提供调用各项服务的API,POSIX.1文档对此作了规范。凡是提供了上述API的操作系统都可被认定为符合POSIX.1标准。
POSIX.1基于UNIX系统调用和C语言库函数,但无需与任何特殊实现相关。这意味着任何操作系统都可以实现该接口,而不一定要是UNIX操作系统。实际上,在不对底层操作系统大加改动的同时,一些厂商通过添加 API 已经使自己的专有操作系统符合了 POSIX.1标准。
对原有POSIX.1标准的若干扩展也同样重要。正式获批于1993年的IEEE POSIX 1003.1b(POSIX.1b,原名POSIX.4或POSIX 1003.4)包含了一系列对基本POSIX标准的实时性扩展。正式获批于1995年的IEEE POSIX 1003.1c(POSIX.1c)对POSIX线程作了定义。1996年,一个经过修订POSIX.1版本诞生,在核心内容保持不变的同时,并入了实时性和线程扩展。IEEE POSIX 1003.1g(POSIX.1g)定义了包括套接字在内的网络API。分别获批于1999年和2000年的IEEE POSIX 1003.1d(POSIX.1d)和POSIX.1j在POSIX基本标准的基础上,定义了附加的实时性扩展。
POSIX.1b实时性扩展包括文件同步、异步I/O、进程调度、高精度时钟和定时器、采用信号量、共享内存,以及消息队列的进程间通信。这3种进程间通信方法的称谓前通常冠以POSIX,以示其有别于与之类似而又较为古老的System V信号、共享内存以及消息队列。
POSIX.2(1992,ISO/IEC 9945-2:1993)这一与POSIX.1相关的标准,对shell和包括C编译器命令行接口在内的各种UNIX工具进行了标准化。
FIPS 是Federal Information Processing Standard(联邦信息处理标准)的缩写,这套标准由美国政府为规范其对计算机系统的采购而制定。FIPS 151-1于1989年发布。这份标准基于1988年的IEEE POSIX.1标准和ANSI C语言标准草案。FIPS 151-1和POSIX.1(1988)之间的主要差别在于:某些对后者来说是可选的特性,对于前者来说是必须的。由于美国政府是计算机系统的“大买家”,大多数计算机厂商都会确保其UNIX系统符合FIPS 151-1版本的POSIX.1规范。
FIPS 151-2与POSIX.1的1990 ISO版保持一致,但在其他方面则保持不变。2000年2月,已然过时的FIPS 151-2标准被废止。
X/Open公司是由多家国际计算机厂商所组成的联盟,致力于采纳和改进现有标准,以制定出一套全面而又一致的开放系统标准。该公司编纂的《X/Open 可移植性指南》是一套基于POSIX标准的可移植性指导丛书。这份指南的首个重要版本是1989年发布的第三号(XPG3),XPG4随之于1992年发布。1994年,X/Open又对XPG4做了修订,从而诞生了XPG4版本2,其中吸收了1.3.7节所述AT&T System V接口定义第三号中的重要内容。也将这一修订版称为Spec 1170,而1170是指标准中所定义的接口(函数、头文件及命令)数量。
1993年初,Novell从AT&T收购了UNIX系统的相关业务,又在稍后放弃了这项业务,并将UNIX商标权转让给了X/Open。(这一转让计划公布于1993年,但法律限制将这一转让推迟到1994年初。)随后,X/Open又将XPG4版本2“重新包装”为SUS(Single UNIX Specification)(有时,也叫SUSv1)或称之为UNIX95。其内容包括:XPG4版本2,X/Open Curses规范第4号版本2,以及X/Opena联网服务(XNS)规范第4号。SUS版本2(SUSv2,http://www.unix.org/version2/ online.html)发布于1997年,人们也将经过该规范认证的UNIX实现称为UNIX 98。(有时,该规范也被称之为XPG5。)
1996年,X/Open与开放软件基金会(OSF)合并,成立The Open Group。如今,几乎每家与UNIX系统有关的公司或组织都是The Open Group的会员,该组织持续着对API标准的开发。
OSF是20世纪80年代末UNIX纷争期间成立的两家厂商联盟之一。OSF的主要成员包括Digital、IBM、HP、Apollo、Bull、Nixdorf和Siemens。OSF成立的主要目的是为了应对由AT&T(UNIX的发明者)和SUN公司(UNIX工作站市场的领跑者)结盟所带来的威胁。随之,AT&T、SUN和其他公司结成了与OSF对抗的UNIX International联盟。
始于1999年,出于修订并加强POSIX标准和SUS规范的目的,IEEE、Open集团以及ISO/ IEC联合技术委员会共同成立了奥斯丁公共标准修订工作组(CSRG,http://www.opengroup.org/ austin/)。(该工作组的首次会议于1998年9月在德州奥斯丁召开,这也是奥斯丁工作组名称的由来。)2001年12月,该工作组正式批准了POSIX 1003.1-2001,有时简称为POSIX.1-2001(随后,又获批为ISO标准:ISO/IEC 9945:2002)。
POSIX 1003.1-2001取代了SUSv2、POSIX.1、POSIX.2以及大批的早期POSIX标准。有时,人们也将该标准称为Single Unix Specification版本3,本书在后续内容中将称其为SUSv3。
SUSv3基本规范约有3700页,分为以下4部分。
此外,SUSv3还包含了X/Open CURSES第4号版本2(XCURSES)规范,该规范针对curses屏幕处理API定义了372个函数和3个头文件。
在SUSv3中共计定义了1742个接口。与之形成鲜明对照的是,POSIX.1-1990(连同FIPS 151-2)定义了199个接口,POSIX.2-1992定义了130个实用工具。
SUSv3规范可在线获得,网址是http://www.unix.org/version3/online.html。通过SUSv3认证的UNIX实现可被称为UNIX 03。
自SUSv3获批以来,人们针对规范文本中所发现的问题进行了多次小规模的修复和改进。因此而诞生的1号技术勘误表并入了2003年发布的SUSv3修订版,而2号技术勘误表的改进成果则并入了SUSv3 2004修订版。
回顾历史,SUS(和XPG)标准顺应了相应POSIX标准,并被组织为POSIX的功能超集。除了对许多额外接口作出规范外,SUS标准还将诸多被POSIX视为可选的接口和行为规范作为必备项。
对于身兼IEEE标准和OPEN集团技术标准的POSIX 1003.1-2001来说(如前所述,POSIX 1003.1-2001是由早期的POSIX和SUS标准合并而成),上述区别的存在方式更显微妙。该文档定义了对规范的两级符合度。
人们将XSI规范符合度达标所需的额外接口和行为统称为XSI扩展。这些扩展支持以下特性:线程、mmap()和munmap()、dlopen API、资源限制、伪终端、System V IPC、syslog API、poll()以及登录记账。
后续各章所言及的“符合SUSv3规范”是指“符合XSI规范”。
由于POSIX和SUSv3目前由同一份文档描述,故而在文档的正文中,对于满足SUSv3符合度所需的额外接口和强制选项都以阴影和边注形式加以标明。
有时,我们会称某些接口在SUSv3中“未定义”或“未明确定义”。
未定义的接口是指尽管偶尔会在背景和原理描述中提及,却根本未经正式标准定义过的接口。
未明确定义的接口是指标准虽然包括了该接口,但却未对其重要细节进行规范。(通常是由于现有接口的实现差异导致标准委员会成员无法达成一致性意见。)
“未定义”或“未明确定义”的接口一经使用,在不同UNIX实现间移植应用就很难得到保证。尽管如此,少数此类接口在不同实现下的表现又相当一致。针对这些接口,本书通常会在提及时一一指出。
书中有时会指出SUSv3将某个特定特性标记为LEGACY。这一术语意味着保留此特性意在与老应用程序保持兼容,而在新应用程序中应避免使用。这也是标准对此特性的限制所在。大多数情况下,都能找到与LEGACY特性等效的其他API。
2008年,奥斯丁工作组完成了对已合并的POSIX和SUS规范的修订工作。较之于之先前版本,该标准包含了基本规范以及XSI扩展。人们将这一修订版本称为SUSv4。
与SUSv3的变化相比,SUSv4的变化范围不算太大。最显著的变化如下所示。
本书后文会就所论及的相关主题指出其在SUSv4中的变化。
图1-1总结了上述各节所述及各种标准之间的关系,并按时间顺序对标准进行了排列。图中的实线表示标准间的直接过渡,虚线则表示标准间有一定的瓜葛,这无非有两种情况:其一,一个标准被并入了另一标准;其二,一个标准依附于另一个标准。
图1-1:各种UNIX和C标准之间的关系图
在网络标准方面,情况稍微有些复杂。该领域的标准化工作始于20世纪80年代末期,成立了POSIX 1003.12委员会,对套接字API、XTI(X/Open传输接口)API(另一套基于System V传输层接口的网络编程API)以及各种相关的API进行规范。该标准的酝酿历时数年,并于2000年获得了批准。其间,POSIX 1003.12被更名为POSIX 1003.1g。
在开发POSIX 1003.1g的同时,X/Open也在开发自己的X/Open网络规范(XNS)。该规范的第一版XNS第4号隶属于SUS首版。其后继版本为XNS第5号,隶属于SUSv2。XNS第5号与当时的POSIX.1g草案(6.6)基本相同。紧随其后的XNS第5.2号与XNS第5号以及获批为标准的POSIX.1g有所不同,将XTI API标记为作废,并纳入了于20世纪90年代中期开发出的IPv6。XNS第5.2号构成了SUSv3中网络编程相关内容的基础,如今已被取代。出于类似原因,POSIX.1g在获批后不久也退出了历史舞台。
除了由独立或多边组织所制定的标准以外,有时,人们也会提到由4.4BSD(BSD的最终版)和SVR4(AT&T的System V Release 4)所定义的两种实现标准。后者随AT&T所发布的SVID(System V定义)而正式出台。1989年,AT&T发布了SVID第3号,定义了自称为System V Release 4的UNIX实现所必须提供的接口。(从http://www.sco.com/developers/devspecs/可以下载到SVID。)
在BSD和SVR4之间,某些系统调用和库函数的行为各不相同,因此,许多UNIX实现都提供了兼容函数库和条件编译工具,可仿效并非特定UNIX实现“本色”的任意一种UNIX特性(请参见3.6.1节)。这减轻了从另一UNIX实现移植应用程序的负担。
遵守各种UNIX标准,尤其是符合POSIX和SUS规范,是Linux(即内核、glibc以及工具)开发的总体目标。可是,在写作本书之际,尚无Linux发行版被The Open group授予“UNIX”商标。造成这一问题的主要原因不外乎是时间和费用。为了获得这一冠名,每个厂商的发行版都要经受规范符合度检查,每当有新的发行版诞生,还需重复执行上述检查。不过,正是由于Linux实际上几近于符合各种UNIX标准,才令其在UNIX市场上如此成功。
对于大多数商业UNIX实现来说,都是由同一家公司来开发和发布操作系统的。Linux则有所不同,其实现与发行是分开的,多家组织——无论是商业性质还是非商业性质——都握有Linux的发行权。
Linus Torvalds并不参与或支持任一特定Linux发行版的发行。然而,就参与Linux开发的其他人而言,情况更为复杂。许多从事Linux内核及其他自由软件项目开发的人员要么受雇于各家Linux发行商,要么就职于对Linux抱有浓厚兴趣的某些公司(诸如IBM和HP)。这些公司允许其程序员为特定Linux项目的开发投入一定的工作时间,这虽然对Linux的发展方向有所影响,但还没有哪家公司能够真正左右Linux的开发。更何况,很多参与Linux和GUN项目的其他开发者都是义工。
写作本书之际,Torvalds受雇成为Linux基金会会员(http://www.linux-foundation.org,之前的开源码发展实验室OSDL),该基金会是一家由多个商业和非商业组织组成的非赢利性联盟,旨在推动Linux的成长。
由于Linux的发行商众多,并且内核的开发者又无法控制Linux发布版的内容,因此还没有诞生“标准”的商业Linux。一般情况下,每家Linux发行商所提供的内核都是基于某特定时间点发布的主要内核(比如Torvalds)版本的快照,最多不过针对其打上几个补丁。发行商普遍认为,这些补丁所提供的特性可以在一定程度上迎合商业需求,从而能够提高市场竞争力。在某些情况下,主要内核版本稍后会打上这些补丁。实际上,某些新内核特性最初正是由某个Linux发行商开发而成,最终被纳入主要内核版本之前,这些新特性早已随着发行商的Linux发布版销售了。例如,被正式纳入主线2.4内核版本之前,版本3的Reiserfs日志文件服务器已经随着某些Linux发布版销售很长时间了。
上面的论述所要说明的就是由不同Linux发行公司提供的系统(往往)存在(细微的)差别。这使人在一定程度上不禁想起在 UNIX 发展之初,其实现方面所存在的各种差异。为了保证不同 Linux 发布版之间的兼容性,LSB 付出了不懈的努力。为了达成上述愿望,LSB(http://www.linux-foundation.org/en/LSB)开发并推广了一套Linux系统标准,其主要目的是用来确保让二进制应用程序(即编译过的程序)能够在任何符合LSB规范的系统上运行。
由LSB所推广的二进制可移植性与POSIX所推广的源码可移植性可谓“一时瑜亮”。源码可移植性是指以C语言编写的程序可在任何符合POSIX规范的系统上编译并运行。而二进制可移植性则要苛刻得多,通常,只要硬件平台不一,便无法实现。二进制可移植性允许我们在某特定平台上将程序一次编译“成型”,然后,便可在任何符合LSB标准的Linux实现上运行该编译好的程序,当然,符合LSB标准的Linux实现必须运行在相同的硬件平台之上。对于在 Linux 上开发应用程序的独立软件开发商来说,二进制可移植性是其生存的基本前提。
1969年,贝尔实验室(AT&T的一个部门)的Ken Thompson在Digital PDP-7小型机上首次实现了UNIX系统。对该操作系统而言,无论是理念还是其双关语的称谓都来源于早期的MULTICS系统。时至1973年,UNIX已经被移植到了PDP-11小型机上,并以C语言对其进行了重写,C编程语言是由贝尔实验室的Dennis Ritchie设计并实现的。因为法律禁止AT&T销售UNIX,于是,在象征性地收取了一定的费用之后,AT&T索性将UNIX系统散布进了大学。这其中便包括了源码,因为这一廉价操作系统的代码可供大学计算机系的师生研究和修改,故而这一操作系统在校园内广受欢迎。
在UNIX系统的开发方面,加州大学伯克利分校扮演了“关键先生”。在该校,Ken Thompson及一干研究生又对这一操作系统进行了“精雕细琢”。到了1979年,这所大学发布了属于自己的UNIX发布版——BSD。这一发布版在学术界广为流传,并在日后成为某些商业UNIX实现的基石。
在此期间,随着AT&T不再对电信市场形成垄断,该公司被获准销售UNIX。这也就催生出了另一种UNIX的变种——System V,日后,它也成为了某些商业UNIX实现的基石。
有两股不同的潮流引领着(GNU/)Linux的开发。其中之一便是由Richard Stallman所创的GNU项目。20世纪80年代末,GNU项目已经开发出了一套几乎完备且可以自由分发的UNIX实现,但独缺一颗能够有效运作的内核。1991年,Linus Torvalds被Minix内核(由Andrew Tanenbaum编写)“灵魂附体”,于是便开发出了一颗能够在Intel x86-32架构上正常运作的内核。应Torvalds之邀,许多其他程序员也加入到了改进内核的行列中。随着时光的流逝,在一干程序员的不懈努力下,Linux逐渐发展壮大,并被移植到了多种硬件架构之上。
20世纪80年代末,UNIX和C语言的实现“百花齐放”,所引发的可移植性问题迫使人们开展针对以上两者的标准化工作。1989年,对C语言的标准化工作完成(C89颁布),在1999年,对C89这一标准进行了修订(C99颁布)。在操作系统接口方面,对其标准化的“第一次吃螃蟹”便催生出了POSIX.1,1988年和1990年,IEEE和ISO先后将POSIX.1采纳为标准。20世纪90年代,人们又开始酝酿一个囊括各版SUS在内的更为详尽的标准。2001年,合二为一的POSIX 1003.1-2001和SUSv3标准颁布。该标准合并并扩展了先前的POSIX标准和各版SUS。2008年,人们完成了对该标准的修订(改动幅度不算太大)工作,于是,合二为一的POSIX 1003.1-2008和SUSv4标准浮出水面。
与大多数商业UNIX实现不同,Linux的开发与发行可谓“风马牛不相及”。因此,并无单一的“官方”Linux发布版。各家Linux发行商所提供的只是当前稳定内核的快照,最多针对其打几个补丁。LSB开发并推广了一套Linux系统标准,其主要目的是用来保证二进制应用程序(即编译过的过程)在不同Linux发布版之间的兼容性,以便编译过的应用程序能够运行在任何符合LSB规范的操作系统上,但前提是操作系统所运行的硬件平台必须相同。
欲知更多有关UNIX历史及标准的信息,请参阅[Ritchie,1984]、[McKusick et al.,1996]、[McKusick & Neville-Neil, 2005]、[Libes & Ressler,1989]、[Garfinkel et al., 2003]、[Stevens & Rago, 2005]、[Stevens, 1999]、[Quartermann & Wilhelm, 1993]、[Goodheart & Cox, 1994]以及[McKusick, 1999]。
[Salus, 1994]是一本详尽的UNIX编年史,本章开篇的许多内容均取自该书。[Salus, 2008]回顾了Linux和其他自由软件项目的简史。此外,与UNIX历史相关的许多细节都可以在Ronda Hauben所著的在线书籍History of UNIX中找到。在http://www.dei.isep.ipp.pt/~acc/docs/unix.html上,可下载到该书。在http://www.levenez.com/unix/上,刊载了一张非常详尽的、显示了各种UNIX实现版本变迁的时间表。
[Josey, 2004]概括了 UNIX系统和SUSv3发展的历史,在指导读者如何使用SUSv3规范的同时,还提供了 SUSv3 所含接口的汇总表,除此之外,还给出了从SUSv2和C89升级到SUSv3和C99的迁移指南。
除了提供软件和文档之外,GNU Web站点(http://www.gnu.org/)还刊载了许多与自由软件项目有关的哲学性文章。[Williams, 2002]是一本Richard Stallman的个人传记。
在[Torvalds & Diamond, 2001]中,Torvalds提供了自己用作Linux开发的私人账户。
本章旨在向Linux和UNIX“生手”们介绍一系列与Linux系统编程有关的概念。
术语“操作系统”通常包含两种不同含义。
1.指完整的软件包,这包括用来管理计算机资源的核心层软件,以及附带的所有标准软件工具,诸如命令行解释器、图形用户界面、文件操作工具和文本编辑器等。
2.在更狭义的范围内,是指管理和分配计算机资源(即CPU、RAM和设备)的核心层软件。
术语“内核”通常是第二种含义,本书中的“操作系统”一词也是这层意思。
虽然在没有内核的情况下,计算机也能运行程序,但有了内核会极大简化其他程序的编写和使用,令程序员“功力”大进、游刃有余。这要归功于内核为管理计算机的有限资源所提供的软件层。
一般情况下,Linux内核可执行文件采用/boot/vmlinuz或与之类似的路径名。而文件名的来历也颇有渊源。早期的UNIX实现称其内核为UNIX。在后续实现了虚拟内存机制的UNIX系统中,其内核名称变更为vmunix。对Linux来说,文件名称中的系统名需要调整,而以“z”替换“linux”末尾的“x”,意在表明内核是经过压缩的可执行文件。
内核所能执行的主要任务如下所示。
除了上述特性外,一般而言,诸如Linux之类的多用户操作系统会为每个用户营造一种抽象:虚拟私有计算机(virtual private computer)。这就是说,每个用户都可以登录进入系统,独立操作,而与其他用户大致无干。例如,每个用户都有属于自己的磁盘存储空间(主目录)。再者,用户能够运行程序,而每一程序都能从CPU资源中“分得一杯羹”,运转于自有的虚拟地址空间中。而且这些程序还能独立访问设备,并通过网络传递信息。内核负责解决(多进程)访问硬件资源时可能引发的冲突,用户和进程对此则往往一无所知。
现代处理器架构一般允许CPU至少在两种不同状态下运行,即:用户态和核心态(有时也称之为监管态supervisor mode)。执行硬件指令可使CPU在两种状态间来回切换。与之对应,可将虚拟内存区域划分(标记)为用户空间部分或内核空间部分。在用户态下运行时,CPU只能访问被标记为用户空间的内存,试图访问属于内核空间的内存会引发硬件异常。当运行于核心态时,CPU既能访问用户空间内存,也能访问内核空间内存。
仅当处理器在核心态运行时,才能执行某些特定操作。这样的例子包括:执行宕机(halt)指令去关闭系统,访问内存管理硬件,以及设备I/O操作的初始化等。实现者们利用这一硬件设计,将操作系统置于内核空间。这确保了用户进程既不能访问内核指令和数据结构,也无法执行不利于系统运行的操作。
在完成诸多日常编程任务时,程序员们习惯于以面向进程(process-oriented)的思维方式来考虑编程问题。然而,在研究本书后续所涵盖的各种主题时,读者有必要转换视角,站在内核的角度上来看问题。为突显二者间的差异,本书接下来会分别从进程和内核视角来检视系统。
一个运行系统通常会有多个进程并行其中。对进程来说,许多事件的发生都无法预期。执行中的进程不清楚自己对CPU的占用何时“到期”,系统随之又会调度哪个进程来使用CPU(以及以何种顺序来调度),也不知道自己何时会再次获得对CPU的使用。信号的传递和进程间通信事件的触发由内核统一协调,对进程而言,随时可能发生。诸如此类,进程都一无所知。进程不清楚自己在RAM中的位置。或者换种更通用的说法,进程内存空间的某块特定部分如今到底是驻留在内存中还是被保存在交换空间(磁盘空间中的保留区域,作为计算机RAM的补充)里,进程本身并不知晓。与之类似,进程也闹不清自己所访问的文件“居于”磁盘驱动器的何处,只是通过名称来引用文件而已。进程的运作方式堪称“与世隔绝”——进程间彼此不能直接通信。进程本身无法创建出新进程,哪怕“自行了断”都不行。最后还有一点,进程也不能与计算机外接的输入输出设备直接通信。
相形之下,内核则是运行系统的中枢所在,对于系统的一切无所不知、无所不能,为系统上所有进程的运行提供便利。由哪个进程来接掌对 CPU 的使用,何时“接任”,“任期”多久,都由内核说了算。在内核维护的数据结构中,包含了与所有正在运行的进程有关的信息。随着进程的创建、状态发生变化或者终结,内核会及时更新这些数据结构。内核所维护的底层数据结构可将程序使用的文件名转换为磁盘的物理位置。此外,每个进程的虚拟内存与计算机物理内存及磁盘交换区之间的映射关系,也在内核维护的数据结构之列。进程间的所有通信都要通过内核提供的通信机制来完成。响应进程发出的请求,内核会创建新的进程,终结现有进程。最后,由内核(特别是设备驱动程序)来执行与输入/输出设备之间的所有直接通信,按需与用户进程交互信息。
本书后续内容中会出现如下措辞,例如:“某进程可创建另一个进程”、“某进程可创建管道”、“某进程可将数据写入文件”,以及“调用exit()以终止某进程”。请务必牢记,以上所有动作都是由内核来居中“调停”,上面的说法不过是“某进程可以请求内核创建另一个进程”的缩略语,以此类推。
涵盖操作系统概念和设计,尤其是对 UNIX 操作系统加以重点关注的现代教科书包括:[Tanenbaum, 2007]、[Tanenbaum & Woodhull,2006]以及[Vahalia, 1996],最后一本包含了与虚拟内存架构有关的详细内容。[Goodheart & Cox, 1994]详细介绍了System V Release 4。[Maxwell, 1999]则是有选择性地针对 Linux 2.2.5 的部分内核源码进行了注释。[Lions, 1996]对第六版UNIX源码进行了详尽阐释,并一直是研究UNIX操作系统内幕的入门级经典。[Bovet & Cesati, 2005]描述了Linux2.6内核的实现。
shell是一种具有特殊用途的程序,主要用于读取用户输入的命令,并执行相应的程序以响应命令。有时,人们也称之为命令解释器。
术语登录shell(login shell)是指用户刚登录系统时,由系统创建,用以运行shell的进程。尽管某些操作系统将命令解释器集成于内核中,而对 UNIX 系统而言,shell 只是一个用户进程。shell的种类繁多,登入同一台计算机的不同用户同时可使用不同的shell(就单个用户来说,情况也一样)。纵观UNIX历史,出现过以下几种重要的shell。
POSIX.2-1992基于当时的Korn shell版本定义了一个shell标准。如今,Korn shell和bash都符合POSIX规范,但两者都提供了大量对标准的扩展,其扩展之间存在许多差异。
设计shell的目的不仅仅是用于人机交互,对shell脚本(包含shell命令的文本文件)进行解释也是其用途之一。为实现这一目的,每款shell都内置有许多通常与编程语言相关的功能,其中包括变量、循环和条件语句、I/O命令以及函数等。
尽管在语法方面有所差异,每款shell执行的任务都大致相同。除非指明是某款特定shell的操作,否则书中的“shell”都会按所描述的方式运作。本书绝大多数需要用到shell的示例都会使用bash,若无其他说明,读者可假定这些示例也能以相同方式在其他类Bourne的shell上运行。
系统会对每个用户的身份做唯一标识,用户可隶属于多个组。
系统的每个用户都拥有唯一的登录名(用户名)和与之相对应的整数型用户ID(UID)。系统密码文件/etc/passwd为每个用户都定义有一行记录,除了上述两项信息外,该记录还包含如下信息。
该记录还能以加密形式保存用户密码。然而,出于安全考虑,用户密码往往存储于单独的shadow密码文件中,仅供特权用户阅读。
出于管理目的,尤其是为了控制对文件和其他资源的访问,将多个用户分组是非常实用的做法。例如,某项目的开发团队人员需要共享同一组文件,就可以将他们编为同一组的成员。在早期的UNIX实现中,一个用户只能隶属于一个组。BSD率先允许一个用户同时属于多个组,这一理念后来被其他UNIX实现纷纷效仿,并最终成为POSIX.1-1990标准。每个用户组都对应着系统组文件/etc/group中的一行记录,该记录包含如下信息。
超级用户在系统中享有特权。超级用户账号的用户ID为0,通常登录名为root。在一般的UNIX系统上,超级用户凌驾于系统的权限检查之上。因此,无论对文件施以何种访问权限限制,超级用户都可以访问系统中的任何文件,也能发送信号干预系统运行的所有用户进程。系统管理员可以使用超级用户账号来执行各种系统管理任务。
内核维护着一套单根目录结构,以放置系统的所有文件。(这与微软Windows之类的操作系统形成了鲜明对照,Windows系统的每个磁盘设备都有各自的目录层级。)这一目录层级的根基就是名为“/”的根目录。所有的文件和目录都是根目录的“子孙”。图 1-2 所示为这种文件层级结构的示例。
在文件系统内,会对文件类型进行标记,以表明其种类。其中一种用来表示普通数据文件,人们常称之为“普通文件”或“纯文本文件”,以示与其他种类的文件有所区别。其他文件类型包括设备、管道、套接字、目录以及符号链接。
术语“文件”常用来指代任意类型的文件,不仅仅指普通文件。
目录是一种特殊类型的文件,内容采用表格形式,数据项包括文件名以及对相应文件的引用。这一“文件名+引用”的组合被称为链接。每个文件都可以有多条链接,因而也可以有多个名称,在相同或不同的目录中出现。
目录可包含指向文件或其他目录的链接。路径间的链接建立起如图2-1所示的目录层级。
图2-1:Linux单根目录层级的一部分
每个目录至少包含两条记录:.和..,前者是指向目录自身的链接,后者是指向其上级目录—— 父目录的链接。除根目录外,每个目录都有父目录。对于根目录而言,..是指向根目录自身的链接(因此,/..等于/)。
类似于普通链接,符号链接给文件起了一个“别号(alternative name)”。在目录列表中,普通链接是内容为“文件名+指针”的一条记录,而符号链接则是经过特殊标记的文件,内容包含了另一文件的名称。(换言之,一个符号链接对应着目录中内容为“文件名+指针”的一条记录,指针指向的文件内容①为另一个文件名的字符串。)所谓“另一文件”通常被称为符号链接的目标,人们一般会说符号链接“指向”或“引用”目标文件。在多数情况下,只要系统调用用到了路径名,内核会自动解除(换言之,按照)该路径名中符号链接的引用,以符号链接所指向的文件名来替换符号链接。若符号链接的目标文件自身也是一个符号链接,那么上述过程会以递归方式重复下去。(为了应对可能出现的循环引用,内核对解除引用的次数作了限制。)如果符号链接指向的文件并不存在,那么可将该链接视为空链接(dangling link)。
通常,人们会分别使用硬链接(hard link)或软链接(soft link)这样的术语来指代正常链接和符号链接。之所以存在这两种不同类型的链接,将在第18章做出解释。
在大多数Linux文件系统上,文件名最长可达255个字符。文件名可以包含除“/”和空字符(\0)外的所有字符。但是,只建议使用字母、数字、点(“.”)、下划线(“_”)以及连字符(“−”)。SUSv3将这65个字符的集合[-._a-zA-Z0-9]称为可移植文件名字符集(portable filename character set)。
对于可移植文件名字符集以外的字符,由于其可能会在shell、正则表达式或其他场景中具有特殊含义,故而应避免在文件名中使用。如在上述环境中出现了包含特殊含义字符的文件名,则需要进行转义,即对此类字符进行特殊标记(一般会在特殊字符前插入一个“\”),以指明不应以特殊含义对其进行解释。若场境不支持转义机制,则不能使用此类文件名。
此外,还应避免以连字符(“-”)作为文件名的起始字符,因为一旦在shell命令中使用这种文件名,会被误认为命令行选项开关。
路径名是由一系列文件名组成的字符串,彼此以“/”分隔,首字符可以为“/”(非强制)②。除却最后一个文件名外,该系列文件名均为目录名称(或为指向目录的符号链接)。路径名的尾部③可标识任意类型的文件,包括目录在内。有时将该字符串中最后一个“/”字符之前的部分称为路径名的目录部分,将其之后的部分称为路径名的文件部分或基础部分。
路径名应按从左至右的顺序阅读,路径名中每个文件名之前的部分,即为该文件所处目录。可在路径名中任意位置后引入字符串“..”④,用以指代路径名中当前位置的父目录。
路径名描述了单根目录层级下的文件位置,又可分为绝对路径名和相对路径名:
每个进程都有一个当前工作目录(有时简称为进程工作目录或当前目录)。这就是单根目录层级下进程的“当前位置”,也是进程解释相对路径名的参照点。
进程的当前工作目录继承自其父进程。对登录shell来说,其初始当前工作目录,是依据密码文件中该用户记录的主目录字段来设置。可使用cd命令来改变shell的当前工作目录。
每个文件都有一个与之相关的用户ID和组ID,分别定义文件的属主和属组。系统根据文件的所有权来判定用户对文件的访问权限。
为了访问文件,系统把用户分为3类:文件的属主(有时,也称为文件的用户)、与文件组(group)ID相匹配的属组成员用户以及其他用户。可为以上3类用户分别设置3种权限(共计9种权限位):只允许查看文件内容的读权限;允许修改文件内容的写权限;允许执行文件的执行权限。这里的文件要么指程序,要么是交由某种解释程序(通常指shell的一种,但也有例外)处理的脚本。
也可针对目录进行上述权限设置,但意义稍有不同。读权限允许列出目录内容(即该目录下的文件名),写权限允许对目录内容进行更改(比如,添加、修改或删除文件名),执行(有时也称为搜索)权限允许对目录中的文件进行访问(但需受文件自身访问权限的约束)。
UNIX系统I/O模型最为显著的特性之一是其I/O通用性概念。也就是说,同一套系统调用(open()、read()、write()、close()等)所执行的I/O操作,可施之于所有文件类型,包括设备文件在内。(应用程序发起的I/O请求,内核会将其转化为相应的文件系统操作,或者设备驱动程序操作,以此来执行针对目标文件或设备的I/O操作。)因此,采用这些系统调用的程序能够处理任何类型的文件。
就本质而言,内核只提供一种文件类型:字节流序列,在处理磁盘文件、磁盘或磁带设备时,可通过lseek()系统调用来随机访问。
许多应用程序和函数库都将新行符(十进制ASCII码为10,有时亦称其为换行)视为文本中一行的结束和另一行的开始。UNIX系统没有文件结束符的概念,读取文件时如无数据返回,便会认定抵达文件末尾。
I/O 系统调用使用文件描述符——(往往是数值很小的)非负整数——来指代打开的文件。获取文件描述符的常用手法是调用 open(),在参数中指定 I/O 操作目标文件的路径名。
通常,由shell启动的进程会继承3个已打开的文件描述符:描述符0为标准输入,指代为进程提供输入的文件;描述符1为标准输出,指代供进程写入输出的文件;描述符2为标准错误,指代供进程写入错误消息或异常通告的文件。在交互式shell或程序中,上述三者一般都指向终端。在stdio函数库中,这几种描述符分别与文件流stdin、stdout和stderr相对应。
C编程语言在执行文件I/O操作时,往往会调用C语言标准库的I/O函数。也将这样一组I/O函数称为stdio函数库,其中包括fopen()、fclose()、scanf()、printf()、fgets()、fputs()等。stdio函数位于I/O系统调用层(open()、close()、read()、write()等)之上。
本书假定读者已经了解了C语言的标准I/O(stdio)函数,因此也不会介绍这方面的内容。更多与stdio函数库有关的信息请参考[Kernighan & Ritchie, 1988]、[Harbison & Steele,2002]、[Plauger、1992]和[Stevens & Rago, 2005]。
程序通常以两种面目示人。其一为源码形式,由使用编程语言(比如,C语言)写成的一系列语句组成,是人类可以阅读的文本文件。要想执行程序,则需将源码转换为第二种形式——计算机可以理解的二进制机器语言指令。(这与脚本形成了鲜明对照,脚本是包含命令的文本文件,可以由shell或其他命令解释器之类的程序直接处理。)一般认为,术语“程序”的上述两种含义几近相同,因为经过编译和链接处理,会将源码转换为语义相同的二进制机器码。
从stdin读取输入,加以转换,再将转换后的数据输出到stdout,常常将拥有上述行为的程序称为过滤器,cat、grep、tr、sort、wc、sed、awk均在其列。
C语言程序可以访问命令行参数,即程序运行时在命令行中输入的内容。要访问命令行参数,程序的main()函数需做如下声明:
argc变量包含命令行参数的总个数,argv指针数组的成员指针则逐一指向每个命令行参数字符串。首个字符串argv[0],标识程序名本身。
简而言之,进程是正在执行的程序实例。执行程序时,内核会将程序代码载入虚拟内存,为程序变量分配空间,建立内核记账(bookkeeping)数据结构,以记录与进程有关的各种信息(比如,进程ID、用户ID、组ID以及终止状态等)。
在内核看来,进程是一个个实体,内核必须在它们之间共享各种计算机资源。对于像内存这样的受限资源来说,内核一开始会为进程分配一定数量的资源,并在进程的生命周期内,统筹该进程和整个系统对资源的需求,对这一分配进行调整。程序终止时,内核会释放所有此类资源,供其他进程重新使用。其他资源(如CPU、网络带宽等)都属于可再生资源,但必须在所有进程间平等共享。
逻辑上将一个进程划分为以下几部分(也称为段)。
进程可使用系统调用fork()来创建一个新进程。调用fork()的进程被称为父进程,新创建的进程则被称为子进程。内核通过对父进程的复制来创建子进程。子进程从父进程处继承数据段、栈段以及堆段的副本后,可以修改这些内容,不会影响父进程的“原版”内容。(在内存中被标记为只读的程序文本段则由父、子进程共享。)
然后,子进程要么去执行与父进程共享代码段中的另一组不同函数,或者,更为常见的情况是使用系统调用execve()去加载并执行一个全新程序。execve()会销毁现有的文本段、数据段、栈段及堆段,并根据新程序的代码,创建新段来替换它们。
以execve()为基础,C语言库还提供了几个相关函数,接口虽然略有不同,但功能全都相同。以上所有库函数的名称均以字符串“exec”打头,在函数间差异无关宏旨的场合,本书会用符号exec()作为这些库函数的统称。不过,请读者牢记,实际上根本不存在名为exec()的库函数。
一般情况下,书中会使用“执行”一词来指代execve()及其衍生函数所实施的操作。
每一进程都有一个唯一的整数型进程标识符(PID)。此外,每一进程还具有一个父进程标识符(PPID)属性,用以标识请求内核创建自己的进程。
可使用以下两种方式之一来终止一个进程:其一,进程可使用_exit()系统调用(或相关的exit()库函数),请求退出;其二,向进程传递信号,将其“杀死”。无论以何种方式退出,进程都会生成“终止状态”,一个非负小整数,可供父进程的wait()系统调用检测。在调用_exit()的情况下,进程会指明自己的终止状态。若由信号来“杀死”进程,则会根据导致进程“死亡”的信号类型来设置进程的终止状态。(有时会将传递进_exit()的参数称为进程的“退出状态”,以示与终止状态有所不同,后者要么指传递给_exit()的参数值,要么表示“杀死”进程的信号。)
根据惯例,终止状态为0表示进程“功成身退”,非0则表示有错误发生。大多数shell会将前一执行程序的终止状态保存于shell变量$?中。
每个进程都有一组与之相关的用户ID (UID)和组ID (GID),如下所示。
在UNIX系统上,就传统意义而言,特权进程是指有效用户ID为0(超级用户)的进程。通常由内核所施加的权限限制对此类进程无效。与之相反,术语“无特权”(或非特权)进程是指由其他用户运行的进程。此类进程的有效用户ID为非0值,且必须遵守由内核所强加的权限规则。
由某一特权进程创建的进程,也可以是特权进程。例如,一个由root(超级用户)发起的登录shell。成为特权进程的另一方法是利用set-user-ID机制,该机制允许某进程的有效用户ID等同于该进程所执行程序文件的用户ID。
始于内核2.2,Linux把传统上赋予超级用户的权限划分为一组相互独立的单元(称之为“能力”)。每次特权操作都与特定的能力相关,仅当进程具有特定能力时,才能执行相应操作。传统意义上的超级用户进程(有效用户ID为0)则相应开启了所有能力。
赋予某进程部分能力,使得其既能够执行某些特权级操作,又防止其执行其他特权级操作。
本书第39章会对能力做深入讨论。在本书后文中,当述及只能由特权进程执行的特殊操作时,一般都会在括号中标明其具体能力。能力的命名以CAP_为前缀,例如,CAP_KILL。
系统引导时,内核会创建一个名为init的特殊进程,即“所有进程之父”,该进程的相应程序文件为/sbin/init。系统的所有进程不是由init(使用 frok())“亲自”创建,就是由其后代进程创建。init进程的进程号总为1,且总是以超级用户权限运行。谁(哪怕是超级用户)都不能“杀死”init进程,只有关闭系统才能终止该进程。init 的主要任务是创建并监控系统运行所需的一系列进程。(手册页init(8)中包含了init进程的详细信息。)
守护进程指的是具有特殊用途的进程,系统创建和处理此类进程的方式与其他进程相同,但以下特征是其所独有的:
守护进程中的例子有syslogd(在系统日志中记录消息)和httpd(利用HTTP分发Web页面)。
每个进程都有一份环境列表,即在进程用户空间内存中维护的一组环境变量。这份列表的每一元素都由一个名称及其相关值组成。由fork()创建的新进程,会继承父进程的环境副本。这也为父子进程间通信提供了一种机制。当进程调用exec()替换当前正在运行的程序时,新程序要么继承老程序的环境,要么在exec()调用的参数中指定新环境并加以接收。
在绝大多数shell中,可使用export命令来创建环境变量(C shell使用setenv命令),如下所示:
本书在展示交互式输入、输出的shell会话日志时,总是以黑体字来呈现输入文本。有时也会在日志中以斜体字形式加注,以解释输入的命令和产生的输出。
C语言程序可使用外部变量(char **environ)来访问环境,而库函数也允许进程去获取或修改自己环境中的值。
环境变量的用途多种多样。例如,shell定义并使用了一系列变量,供shell执行的脚本和程序访问。其中包括:变量HOME(明确定义了用户登录目录的路径名)、变量PATH(指明了用户输入命令后,shell查找与之相应程序时所搜索的目录列表)。
每个进程都会消耗诸如打开文件、内存以及CPU时间之类的资源。使用系统调用setrlimit(),进程可为自己消耗的各类资源设定一个上限。此类资源限制的每一项均有两个相关值:软限制(soft limit)限制了进程可以消耗的资源总量,硬限制(hard limit)软限制的调整上限。非特权进程在针对特定资源调整软限制值时,可将其设置为0到相应硬限制值之间的任意值,但硬限制值则只能调低,不能调高。
由fork()创建的新进程,会继承其父进程对资源限制的设置。
使用ulimit命令(在C shell中为limit)可调整shell的资源限制。shell为执行命令所创建的子进程会继承上述资源设置。
调用系统函数mmap()的进程,会在其虚拟地址空间中创建一个新的内存映射。
映射分为两类。
由某一进程所映射的内存可以与其他进程的映射共享。达成共享的方式有二:其一是两个进程都针对某一文件的相同部分加以映射,其二是由fork()创建的子进程自父进程处继承映射。当两个或多个进程共享的页面相同时,进程之一对页面内容的改动是否为其他进程所见呢?这取决于创建映射时所传入的标志参数。若传入标志为私有,则某进程对映射内容的修改对于其他进程是不可见的,而且这些改动也不会真地落实到文件上;若传入标志为共享,对映射内容的修改就会为其他进程所见,并且这些修改也会造成对文件的改动。内存映射用途很多,其中包括:以可执行文件的相应段来初始化进程的文本段、内存(内容填充为0)分配、文件I/O(即映射内存I/O)以及进程间通信(通过共享映射)。
所谓目标库是这样一种文件:将(通常是逻辑相关的)一组函数代码加以编译,并置于一个文件中,供其他应用程序调用。这一做法有利于程序的开发和维护。现代UNIX系统提供两种类型的对象库:静态库和共享库。
静态库(有时,也称之为档案文件[archives])是早期UNIX系统中唯一的一种目标库。本质上说来,静态库是对已编译目标模块的一种结构化整合。要使用静态库中的函数,需要在创建程序的链接命令中指定相应的库。主程序会对静态库中隶属于各目标模块的不同函数加以引用。链接器在解析了引用情况后,会从库中抽取所需目标模块的副本,将其复制到最终的可执行文件中,这就是所谓静态链接。对于所需库内的各目标模块,采用静态链接方式生成的程序都存有一份副本。这会引起诸多不便。其一,在不同的可执行文件中,可能都存有相同目标代码的副本,这是对磁盘空间的浪费。同理,调用同一库函数的程序,若均以静态链接方式生成,且又于同时加以执行,这会造成内存浪费,因为每个程序所调用的函数都各有一份副本驻留在内存中,此其二。此外,如果对库函数进行了修改,需要重新加以编译、生成新的静态库,而所有需要调用该函数“更新版”的应用,都必须与新生成的静态库重新链接。
设计共享库的目的是为了解决静态库所存在的问题。
如果将程序链接到共享库,那么链接器就不会把库中的目标模块复制到可执行文件中,而是在可执行文件中写入一条记录,以表明可执行文件在运行时需要使用该共享库。一旦在运行时将可执行文件载入内存,一款名为“动态链接器”的程序会确保将可执行文件所需的动态库找到,并载入内存,随后实施运行时链接,解析可执行文件中的函数调用,将其与共享库中相应的函数定义关联起来。在运行时,共享库代码在内存中只需保留一份,且可供所有运行中的程序使用。
经过编译处理的函数仅在共享库内保存一份,从而节约了磁盘空间。另外,这一设计还能确保各类程序及时使用到函数的最新版本,功莫大焉,只需将带有函数新定义体的共享库重新加以编译即可,程序会在下次执行时自动使用新函数。
Linux系统上运行有多个进程,其中许多都是独立运行。然而,有些进程必须相互合作以达成预期目的,因此彼此间需要通信和同步机制。
读写磁盘文件中的信息是进程间通信的方法之一。可是,对许多程序来说,这种方法既慢又缺乏灵活性。因此,像所有现代UNIX实现那样,Linux也提供了丰富的进程间通信(IPC)机制,如下所示。
UNIX系统的IPC机制种类如此繁多,有些功能还互有重叠,部分原因是由于各种IPC机制是在不同的UNIX实现上演变而来的,需要遵循的标准也各不相同。例如,就本质而言,FIFO和UNIX套接字功能相同,允许同一系统上并无关联的进程彼此交换数据。二者之所以并存于现代UNIX系统之中,是由于FIFO来自System V,而套接字则源于BSD。
尽管上一节将信号视为 IPC 的方法之一,但其在其他方面的广泛应用则更为普遍,因此值得深入讨论。
人们往往将信号称为“软件中断”。进程收到信号,就意味着某一事件或异常情况的发生。信号的类型很多,每一种分别标识不同的事件或情况。采用不同的整数来标识各种信号类型,并以SIGxxxx形式的符号名加以定义。
内核、其他进程(只要具有相应的权限)或进程自身均可向进程发送信号。例如,发生下列情况之一时,内核可向进程发送信号。
在shell中,可使用kill命令向进程发送信号。在程序内部,系统调用kill()可提供相同的功能。
收到信号时,进程会根据信号采取如下动作之一。
就大多数信号类型而言,程序可选择不采取默认的信号动作,而是忽略信号(当信号的默认处理行为并非忽略此信号时,会派上用场)或者建立自己的信号处理器。信号处理器是由程序员定义的函数,会在进程收到信号时自动调用,根据信号的产生条件执行相应动作。
信号从产生直至送达进程期间,一直处于挂起状态。通常,系统会在接收进程下次获得调度时,将处于挂起状态的信号同时送达。如果接收进程正在运行,则会立即将信号送达。然而,程序可以将信号纳入所谓“信号屏蔽”⑤以求阻塞该信号。如果产生的信号处于“信号屏蔽”之列,那么此信号将一直保持挂起状态,直至解除对该信号的阻塞。(亦即从信号屏蔽中移除。)
在现代UNIX实现中,每个进程都可执行多个线程。可将线程想象为共享同一虚拟内存及一干其他属性的进程。每个线程都会执行相同的程序代码,共享同一数据区域和堆。可是,每个线程都拥有属于自己的栈,用来装载本地变量和函数调用链接信息。
线程之间可通过共享的全局变量进行通信。借助于线程 API 所提供的条件变量和互斥机制,进程所属的线程之间得以相互通信并同步行为——尤其是在对共享变量的使用方面。此外,利用2.10节所述的IPC和同步机制,线程间也能彼此通信。
线程的主要优点在于协同线程之间的数据共享(通过全局变量)更为容易,而且就某些算法而论,以多线程来实现比之以多进程实现要更加自然。再者,显而易见,多线程应用能从多处理器硬件的并行处理中获益匪浅。
shell执行的每个程序都会在一个新进程内发起。比如,shell创建了3个进程来执行以下管道命令(在当前的工作目录下,根据文件大小对文件进行排序并显示):
除Bourne shell以外,几乎所有的主流shell都提供了一种交互式特性,名为任务控制。该特性允许用户同时执行并操纵多条命令或管道。在支持任务控制的shell中,会将管道内的所有进程置于一个新进程组或任务中。(如果情况很简单,shell命令行只包含一条命令,那么就会创建一个只包含单个进程的新进程组。)进程组中的每个进程都具有相同的进程组标识符(以整数形式),其实就是进程组中某个进程(也称为进程组组长process group leader)的进程ID。
内核可对进程组中的所有成员执行各种动作,尤其是信号的传递。如下节所述,支持任务控制的shell会利用这一特性,以挂起或恢复执行管道中的所有进程。
会话指的是一组进程组(任务)。会话中的所有进程都具有相同的会话标识符。会话首进程(session leader)是指创建会话的进程,其进程ID会成为会话ID。
使用会话最多的是支持任务控制的shell,由shell创建的所有进程组与shell自身隶属于同一会话,shell是此会话的会话首进程。
通常,会话都会与某个控制终端相关。控制终端建立于会话首进程初次打开终端设备之时。对于由交互式shell所创建的会话,这恰恰是用户的登录终端。一个终端至多只能成为一个会话的控制终端。
打开控制终端会致使会话首进程成为终端的控制进程。一旦断开了与终端的连接(比如,关闭了终端窗口),控制进程将会收到SIGHUP信号。
在任一时点,会话中总有一个前台进程组(前台任务),可以从终端中读取输入,向终端发送输出。如果用户在控制终端中输入了“中断”(通常是Control-C)或“挂起”字符(通常是Control-Z),那么终端驱动程序会发送信号以终止或挂起(亦即停止)前台进程组。一个会话可以拥有任意数量的后台进程组(后台任务),由以“&”字符结尾的行命令来创建。
支持任务控制的shell提供如下命令:列出所有任务,向任务发送信号,以及在前后台任务之间来回切换。
伪终端是一对相互连接的虚拟设备,也称为主从设备。在这对设备之间,设有一条IPC信道,可供数据进行双向传递。
从设备(slave device)所提供的接口,其行为方式与终端相类似,基于这一特点,可以将某个为终端编写的程序与从设备连接起来,然后,再利用连接到主设备的另一程序来驱动这一“面向终端”的程序,这是伪终端的一个关键用途。由“驱动程序”⑥所产生的输出,在经由终端驱动程序的常规输入处理(例如,默认情况下,会把回车符映射为换行符)后,会作为输入传递给与从设备相连的面向终端的程序。而由面向终端的程序向从设备写入的任何数据又作为“驱动程序”的输入来传递(在执行完所有常规的终端输入处理后)。换句话说,“驱动程序”所履行的功能,在效果上等同于用户通常在传统终端上所执行的操作。
伪终端广泛应用于各种应用领域,最知名的要数telnet和ssh之类提供网络登录服务的应用,以及X Window系统所提供的终端窗口实现。
进程涉及两种类型的时间。
time命令会显示出真实时间、系统CPU时间,以及为执行管道中的多个进程而花费的用户CPU时间。
本书有多处论及客户端/服务器应用程序的设计和实现。
客户端/服务器应用由两个组件进程组成。
有时,服务器与客户端之间可能需要就一次服务而进行多次交互。
客户端应用通常与用户打交道,而服务器应用则提供对某些共享资源的访问。一般说来,都是众多客户端进程与为数不多的一个或几个服务器端进程进行通信。
客户端和服务器既可以驻留于同一台计算机上,也可以位于联网的不同计算机上。客户端和服务器使用2.10节所讨论的IPC机制来实现彼此通信。
服务器可以提供各种服务,如下所示。
将某项服务封装于单独的服务器应用中,这一做法原因很多,举例如下。
实时性应用程序是指那些需要对输入做出及时响应的程序。此类输入往往来自于外接的传感器或某些专门的输入设备,而输出则会去控制外接硬件。具有实时性需求的应用程序示例包括自动化装配流水线、银行ATM机,以及飞机导航系统等。
虽然许多实时性应用程序都要求对输入做出快速响应,但决定性因素却在于要在事件触发后的一定时限内,保证响应的交付。
要提供实时响应,特别是在短时间内加以响应,就需要底层操作系统的支持。由于实时响应的需求与多用户分时操作系统的需求存在冲突,大多数操作系统“天生”并不提供这样的支持。虽然已经设计出不少实时性的UNIX变体,但传统的UNIX实现都不是实时操作系统。Linux的实时性变体也早已诞生,而近期的Linux内核正转向对实时性应用原生而全面的支持。
为支持实时性应用,POSIX.1b定义了多个POSIX.1扩展,其中包括异步I/O、共享内存、内存映射文件、内存锁定、实时性时钟和定时器、备选调度策略、实时性信号、消息队列,以及信号量等。虽然这些扩展还不具备严格意义上的“实时性”,但当今的大多数UNIX实现都支持上面提到的全部或部分扩展(本书将讲解Linux所支持的POSIX.1b特性)。
本书会以术语“真实时间(real time)”来指代日历时间或经历时间的概念,而术语“实时性(realtime)”则是指操作系统或应用程序具备本节所述的响应能力。
类似于其他的几种UNIX实现,Linux也提供了/proc文件系统,由一组目录和文件组成,装配(mount)于/proc目录下。
/proc文件系统是一种虚拟文件系统,以文件系统目录和文件形式,提供一个指向内核数据结构的接口。这为查看和改变各种系统属性开启了方便之门。此外,还能通过一组以/ proc/PID形式命名的目录(PID即进程ID)查看系统中运行各进程的相关信息。
通常,/proc目录下的文件内容都采取人类可读的文本形式,shell脚本也能对其进行解析。程序可以打开、读取和写入/proc目录下的既定文件。大多数情况下,只有特权级进程才能修改/proc目录下的文件内容。
本书在讲解各种Linux编程接口的同时,也会对相关的/proc文件进行介绍。12.1节将就该文件系统的总体信息做进一步介绍。尚无任何标准对/proc文件系统进行过规范,书中与该文件系统相关的细节均为Linux专有。
本章纵览了一系列与Linux系统编程相关的基本概念。对于Linux或UNIX“生手”而言,理解这些基本概念将为学习系统编程提供足够的背景知识。
①译者注:及该文件所属数据块存储的内容。
②译者注:此处“文件”的含义中包括了目录——如前文所述:“目录是一种特殊类型的文件”。
③译者注:即最后一个文件名。
④译者注:需以“/”分隔。
⑤译者注:即一组进程希望阻塞的信号。
⑥译者注:此处专指与主设备相连的程序,而非设备驱动程序之类的含义。
⑦译者注:即本初子午线。
本章将对进程和线程之间用来相互通信和同步操作的工具进行一个简要的介绍,下面的章节将会深入介绍这些工具的细节信息。
图43-1总结了UNIX系统上各种通信和同步工具,并根据功能将它们分成了三类。
尽管其中一些工具关注的是同步,但通用术语进程间通信(IPC)通常指代所有这些工具。
从图43-1中可以看出,通常几个工具会提供类似的IPC功能,之所以会这样是出于下列原因。
图43-1中被分成一组的工具在一些场景中会提供完全不同的功能。如流socket可以用来在网络上通信,而FIFO则只能用来在同一机器上的进程间进行通信。
图43-1:UNIX IPC工具分类
图43-1中列出的各种通信工具允许进程间相互交换数据。(这些工具还可以用来在同一个进程中不同线程之间交换数据,但很少需要这样做,因为线程之间可以通过共享全局变量来交换信息。)
可以将通信工具分成两类。
图43-2:使用管道在两个进程间交换数据
可以进一步将数据传输工具分成下列类别。
数据传输工具和共享内存之间的差别包括以下几个方面。
在socket中可以使用MSG_PEEK标记来执行非破坏性读取(参见61.3节)。UDP(Internet domain datagram)socket允许将一条消息广播或组播到多个接收者处(参见61.12节)。
大多数现代UNIX系统提供了三种形式的共享内存:System V共享内存、POSIX共享内存以及内存映射。在后面介绍这些工具的章节中将会描述它们之间的差别(特别是在54.5节中)。
下面是使用共享内存时的注意点。
通过图43-1中的同步工具可以协调进程的操作。通过同步可以防止进程执行诸如同时更新一块共享内存或同时更新文件的同一个数据块之类的操作。如果没有同步,那么这种同时更新的操作可能会导致应用程序产生错误的结果。
UNIX系统提供了下列同步工具。
一些UNIX实现,包括安装了能提供NPTL线程实现的glibc的Linux系统,允许在进程间共享互斥体和条件变量。SUSv3允许但并不要求实现支持进程间共享的互斥体和条件变量。所有UNIX系统都没有提供这个功能,因此很少使用它们来进行进程同步。
在执行进程间同步时通常需要根据功能需求来选择工具。当协调对文件的访问时文件记录加锁通常是最佳的选择,而对于协调对其他共享资源的访问来讲,信号量通常是更佳的选择。
通信工具也可以用来进行同步。如在44.3节中使用了一个管道来同步父进程与子进程的动作。一般来讲,所有数据传输工具都可以用来同步,只是同步操作是通过在工具中交换消息来完成的。
自内核2.6.22起,Linux通过eventfd()系统调用额外提供了一种非标准的同步机制。这个系统调用创建了一个eventfd对象,该对象拥有一个相关的由内核维护的8字节无符号整数,它返回一个指向该对象的文件描述符。向这个文件描述符中写入一个整数将会把该整数加到对象值上。当对象值为0时对该文件描述符的read()操作将会被阻塞。如果对象的值非零,那么read()会返回该值并将对象值重置为0。此外,可以使用poll()、select()以及epoll来测试对象值是否为非零,如果是非零的话就表示文件描述符可读。使用eventfd对象进行同步的应用程序必须要首先使用eventfd()创建该对象,然后调用fork()创建继承指向该对象的文件描述符的相关进程。更多细节信息可参考eventfd(2)手册。
在需要使用IPC时会发现有很多选择,读者在一开始可能会对这些选择感到迷惑。在后面介绍各个IPC工具的章节中将会把每个工具与其他类似的工具进行比较。下面介绍在确定选择何种IPC工具时通常需要考虑的事项。
要访问一个IPC对象,进程必须要通过某种方式来标识出该对象,一旦将对象“打开”之后,进程必须要使用某种句柄来引用该打开着的对象。表43-1对各种类型的IPC工具的属性进行了总结。
表43-1:各种IPC工具的标识符和句柄
工 具 类 型 |
用于识别对象的名称 |
用于在程序中引用对象的句柄 |
---|---|---|
管道 |
没有名称 |
文件描述符 |
UNIX domain socket |
路径名 |
文件描述符 |
System V消息队列 |
System V IPC键 |
System V IPC标识符 |
POSIX消息队列 |
POSIX IPC路径名 |
mqd_t (消息队列描述符) |
匿名映射 |
没有名称 |
无 |
flock()文件锁 |
路径名 |
文件描述符 |
各种IPC工具在功能上是存在差异的,因此在确定使用何种工具时需要考虑这些差异。下面首先对数据传输工具盒共享内存之间的差异进行总结。
关于各种数据传输工具,下面几点是值得注意的。
在Linux上,POSIX消息队列也是使用文件描述符来实现的,因此也支持上面介绍的各种I/O技术。但SUSv3并没有规定这种行为,因此在大多数实现上并不支持这些技术。
关于进程同步工具,下面几点是值得注意的。
在图43-1中给出所有IPC方法中,只有socket允许进程通过网络来通信。socket一般用于两个域中:一个是UNIX domain,它允许位于同一系统上的进程进行通信;另一个是Internet domain,它允许位于通过TCP/IP网络进行连接的不同主机上的进程进行通信。通常,将一个使用UNIX domain socket进行通信的程序转换成一个使用Internet domain socket进行通信的程序只需要做出微小的改动,这样只需要对使用UNIX domain socket的应用程序做较小的改动就可以将它应用于网络场景。
现代UNIX实现支持图43-1中的大部分IPC工具,但POSIX IPC工具(消息队列、信号量以及共享内存)的普及程度远远不如System V IPC,特别是在较早的UNIX系统上。(只有版本为2.6.x的Linux内核系列才提供了一个POSIX消息队列的实现以及对POSIX信号量的完全支持。)因此,从可移植性的角度来看,System V IPC要优于POSIX IPC。
System V IPC工具被设计成独立于传统的UNIX I/O模型,其结果是其中一些特性使得它的编程接口的用法更加复杂。相应的POSIX IPC工具被设计用来解决这些问题,特别是下面几点需要注意。
相反,内核会为POSIX IPC对象记录打开的引用数,这样就简化了何时删除对象的决策。此外,POSIX IPC提供的接口更加简单并且与传统的UNIX模型也更加一致。
表43-2中的第二列总结了各种IPC工具的一个重要特性:权限模型控制着哪些进程能够访问对象。下面介绍各种模型的细节信息。
表43-2:各种IPC工具的可访问性和持久性
工 具 类 型 |
可 访 问 性 |
持 久 性 |
---|---|---|
管道 |
仅允许相关进程 |
进程 |
UNIX domain socket |
权限掩码 |
进程 |
System V消息队列 |
权限掩码 |
内核 |
POSIX消息队列 |
权限掩码 |
内核 |
匿名映射 |
仅允许相关进程 |
进程 |
flock()文件锁 |
文件的open()操作 |
进程 |
术语持久性是指一个IPC工具的生命周期。(参见表43-2中的第三列。)持久性有三种。
FIFO的数据持久性与其名称的持久性是不同的。FIFO在文件系统中拥有一个名称,当所有引用FIFO的文件描述符都被关闭之后该名称也是持久的。
在一些场景中,不同IPC工具的性能可能存在显著的差异。但在后面的章节中一般不会对它们的性能进行比较,其原因如下。
如果IPC性能是至关紧要的,并且不存在应用程序在与目标系统匹配的环境中运行的性能基准,那么最好编写一个抽象软件层来向应用程序隐藏IPC工具的细节,然后在抽象层下使用不同的IPC工具来测试性能。
本章概述了进程(以及线程)可用来相互通信和同步动作的各种工具。
Linux提供的通信工具包括管道、FIFO、socket、消息队列以及共享内存。Linux提供的同步工具包括信号量和文件锁。
在很多情况下在执行一个给定的任务时存在多种技术可用于通信和同步。本章以多种方式对不同的技术进行了比较,其目标是突出可能对技术选择产生影响的一些差异。
在后面的章节中将会深入介绍各种通信和同步工具。
43-1. 编写一个程序来测量管道的带宽。在命令行参数中,程序应该接收需发送的数据块数目以及每个数据块的大小。在创建一个管道之后,程序将分成两个进程:一个子进程以尽可能快的速度向管道写入数据块,父进程读取数据块。在所有数据都被读取之后,父进程应该打印出所消耗的时间和带宽(每秒传输的字节数)。为不同的数据块大小测量带宽。
43-2. 使用System V消息队列、POSIX消息队列、UNIX domain流socket以及UNIX domain数据报socket来重做上面的练习。使用这些程序来比较各种IPC工具在Linux上的相对性能。读者如果能够使用其他UNIX实现,那么在那些系统上执行同样的比较。
本章介绍管道和FIFO。管道是UNIX系统上最古老的IPC方法,它在20世纪70年代早期UNIX的第三个版本上就出现了。管道为一个常见需求提供了一个优雅的解决方案:给定两个运行不同程序(命令)的进程,在shell中如何让一个进程的输出作为另一个进程的输入呢?管道可以用来在相关进程之间传递数据(读者阅读完后面的几页之后就能够理解“相关”的含义了)。FIFO是管道概念的一个变体,它们之间的一个重要差别在于FIFO可以用于任意进程间的通信。
每个shell用户都对在命令中使用管道比较熟悉,如下面这个统计一个目录中文件的数目的命令所示。
为执行上面的命令,shell创建了两个进程来分别执行ls和wc。(这是通过使用fork()和exec()来完成的,第24章和第27章分别对这两个函数进行了介绍。)图44-1展示了这两个进程是如何使用管道的。
图44-1:使用管道连接两个进程
除了说明管道的用法之外,图44-1的另外一个目的是阐明管道这个名称的由来。可以将管道看成是一组铅管,它允许数据从一个进程流向另一个进程。
在图44-1中有一点值得注意的是两个进程都连接到了管道上,这样写入进程(ls)就将其标准输出(文件描述符为1)连接到了管道的写入端,读取进程(wc)就将其标准输入(文件描述符为0)连接到管道的读取端。实际上,这两个进程并不知道管道的存在,它们只是从标准文件描述符中读取数据和写入数据。shell必须要完成相关的工作,在44.4节中将会介绍shell是如何完成这些工作的。
下面几个段落将会介绍管道的几个重要特征。
当讲到管道是一个字节流时意味着在使用管道时是不存在消息或消息边界的概念的。从管道中读取数据的进程可以读取任意大小的数据块,而不管写入进程写入管道的数据块的大小是什么。此外,通过管道传递的数据是顺序的——从管道中读取出来的字节的顺序与它们被写入管道的顺序是完全一样的。在管道中无法使用lseek()来随机地访问数据。
如果需要在管道中实现离散消息的概念,那么就必须要在应用程序中完成这些工作。虽然这是可行的(参见44.8节),但如果碰到这种需求的话最好使用其他IPC机制,如消息队列和数据报socket,本书在后面几个章节中就会介绍它们。
试图从一个当前为空的管道中读取数据将会被阻塞直到至少有一个字节被写入到管道中为止。如果管道的写入端被关闭了,那么从管道中读取数据的进程在读完管道中剩余的所有数据之后将会看到文件结束(即read()返回0)。
在管道中数据的传递方向是单向的。管道的一段用于写入,另一端则用于读取。
在其他一些UNIX实现上——特别是那些从System V Release 4演化而来的系统——管道是双向的(所谓的流管道)。双向管道并没有在任何UNIX标准中进行规定,因此即使在提供了双向管道的实现上最好也避免依赖这种语义。作为替代方案,可以使用UNIX domain流socket对(通过使用57.5节中介绍的socketpair()系统调用来创建),它提供了一种标准的双向通信机制,并且其语义与流管道是等价的。
如果多个进程写入同一个管道,那么如果它们在一个时刻写入的数据量不超过PIPE_BUF字节,那么就可以确保写入的数据不会发生相互混合的情况。
SUSv3要求PIPE_BUF至少为_POSIX_PIPE_BUF(512)。一个实现应该定义PIPE_BUF(在<limits.h>中)并/或允许调用fpathconf(fd,_PC_PIPE_BUF)来返回原子写入操作的实际上限。不同UNIX实现上的PIPE_BUF不同,如在FreeBSD 6.0其值为512字节,在Tru64 5.1上其值为4096字节,在Solaris 8上其值为5120字节。在Linux上,PIPE_BUF的值为4096。
当写入管道的数据块的大小超过了PIPE_BUF字节,那么内核可能会将数据分割成几个较小的片段来传输,在读者从管道中消耗数据时再附加上后续的数据。(write()调用会阻塞直到所有数据被写入到管道为止。)当只有一个进程向管道写入数据时(通常的情况),PIPE_BUF的取值就没有关系了。但如果有多个写入进程,那么大数据块的写入可能会被分解成任意大小的段(可能会小于PIPE_BUF字节),并且可能会出现与其他进程写入的数据交叉的现象。
只有在数据被传输到管道的时候PIPE_BUF限制才会起作用。当写入的数据达到PIPE_BUF字节时,write()会在必要的时候阻塞直到管道中的可用空间足以原子地完成操作。如果写入的数据大于PIPE_BUF字节,那么write()会尽可能地多传输数据以充满整个管道,然后阻塞直到一些读取进程从管道中移除了数据。如果此类阻塞的write()被一个信号处理器中断了,那么这个调用会被解除阻塞并返回成功传输到管道中的字节数,这个字节数会少于请求写入的字节数(所谓的部分写入)。
在Linux 2.2上,向管道写入任意数量的数据都是原子的,除非写入操作被一个信号处理器中断了。在Linux 2.4以及后续的版本上,写入数据量大于PIPE_BUF字节的所有操作都可能会与其他进程的写入操作发生交叉。(在版本号为2.2和2.4的内核中,实现管道的内核代码存在很大的差异。)
管道其实是一个在内核内存中维护的缓冲器,这个缓冲器的存储能力是有限的。一旦管道被填满之后,后续向该管道的写入操作就会被阻塞直到读者从管道中移除了一些数据为止。
SUSv3并没有规定管道的存储能力。在早于2.6.11的Linux内核中,管道的存储能力与系统页面的大小是一致的(如在x86-32上是4096字节),而从Linux 2.6.11起,管道的存储能力是65,536字节。其他UNIX实现上的管道的存储能力可能是不同的。
一般来讲,一个应用程序无需知道管道的实际存储能力。如果需要防止写者进程阻塞,那么从管道中读取数据的进程应该被设计成以尽可能快的速度从管道中读取数据。
从理论上来讲,没有任何理由可以支持存储能力较小的管道无法正常工作这个结论,哪怕管道的存储能力只有一个字节。使用较大的缓冲器的原因是效率:每当写者充满管道时,内核必须要执行一个上下文切换以允许读者被调度来消耗管道中的一些数据。使用较大的缓冲器意味着需执行的上下文切换次数更少。
从Linux 2.6.35开始就可以修改一个管道的存储能力了。Linux特有的fcntl(fd, F_SETPIPE_SZ, size)调用会将fd引用的管道的存储能力修改为至少size字节。非特权进程可以将管道的存储能力修改为范围在系统的页面大小到/proc/sys/fs/pipe-max-size中规定的值之内的任何一个值。pipe-max-size的默认值是1048576字节。特权(CAP_SYS_RESOURCE)进程可以覆盖这个限制。在为管道分配空间时,内核可能会将size提升为对实现来讲更加便捷的某个值。fcntl(fd, F_GETPIPE_SZ)调用返回为管道分配的实际大小。
pipe()系统调用创建一个新管道。
成功的pipe()调用会在数组filedes中返回两个打开的文件描述符:一个表示管道的读取端(filedes[0]),另一个表示管道的写入端(filedes[1])。
与所有文件描述符一样,可以使用read()和write()系统调用来在管道上执行I/O。一旦向管道的写入端写入数据之后立即就能从管道的读取端读取数据。管道上的read()调用会读取的数据量为所请求的字节数与管道中当前存在的字节数两者之间较小的那个(但当管道为空时阻塞)。
也可以在管道上使用stdio函数(printf()、scanf()等),只需要首先使用fdopen()获取一个与filedes中的某个描述符对应的文件流即可(参见13.7节)。但在这样做的时候需要清楚在44.6节中介绍的stdio缓冲问题。
ioctl(fd, FIONREAD, &cnt)调用返回文件描述符fd所引用的管道或FIFO中未读取的字节数。其他一些实现也提供了这个特性,但SUSv3并没有对此进行规定。
图44-2给出了使用pipe()创建完管道之后的情况,其中调用进程通过文件描述符引用了管道的两端。
图44-2:在创建完管道之后处理文件描述符
在单个进程中管道的用途不多(在63.5.2节中将会介绍一种用途)。一般来讲都是使用管道让两个进程进行通信。为了让两个进程通过管道进行连接,在调用完pipe()之后可以调用fork()。在fork()期间,子进程会继承父进程的文件描述符的副本(参见24.2.1节),这样就会出现图44-3中左边那样的情形。
图44-3:设置管道来将数据从父进程传输到子进程
虽然父进程和子进程都可以从管道中读取和写入数据,但这种做法并不常见。因此,在fork()调用之后,其中一个进程应该立即关闭管道的写入端的描述符,另一个则应该关闭读取端的描述符。如,如果父进程需要向子进程传输数据,那么它就会关闭管道的读取端的描述符filedes[0],而子进程就会关闭管道的写入端的描述符filedes[1],这样就出现了图44-3中右边那样的情形。程序清单44-1给出了创建这个管道的代码。
程序清单44-1:使用管道将数据从父进程传输到子进程所需的步骤
让父进程和子进程都能够从同一个管道中读取和写入数据这种做法并不常见的一个原因是如果两个进程同时试图从管道中读取数据,那么就无法确定哪个进程会首先读取成功——两个进程竞争数据了。要防止这种竞争情况的出现就需要使用某种同步机制。但如果需要双向通信则可以使用一种更加简单的方法:创建两个管道,在两个进程之间发送数据的两个方向上各使用一个。(如果使用这种技术,那么就需要考虑死锁的问题了,因为如果两个进程都试图从空管道中读取数据或尝试向已满的管道中写入数据就可能会发生死锁。)
虽然可以有多个进程向单个管道中写入数据,但通常只存在一个写者。(在44.3节中将会给出一个使用多个写者向一个管道写入数据的例子。)相反,在有些情况下让FIFO拥有多个写者是比较有用的,在44.8节中将会给出一个这样的例子。
从2.6.27内核开始,Linux支持一个全新的非标准系统调用pipe2()。这个系统调用执行的任务与pipe()一样,但支持额外的参数flags,这个参数可以用来修改系统调用的行为。这个系统调用支持两个标记,一个是O_CLOEXEC,它会导致内核为两个新的文件描述符启用close-on-exec标记(FD_CLOEXEC)。这个标记之所以有用的原因与在4.3.1节中介绍的open() O_CLOEXEC标记有用的原因一样。另一个是O_NONBLOCK标记,它会导致内核将底层的打开的文件描述符标记为非阻塞,这样后续的I/O操作会是非阻塞的。这样就能够在不调用fcntl()的情况下达到同样的效果了。
目前为止本章已经介绍了如何使用管道来让父进程和子进程之间进行通信,其实管道可以用于任意两个(或更多)相关进程之间的通信,只要在创建子进程的系列fork()调用之前通过一个共同的祖先进程创建管道即可。(这就是本章开头部分所讲的“相关进程”的含义。)如管道可用于一个进程和其孙子进程之间的通信。第一个进程创建管道,然后创建子进程,接着子进程再创建第一个进程的孙子进程。管道通常用于两个兄弟进程之间的通信——它们的父进程创建了管道,然后创建两个子进程。这就是在构建管道线时shell所做的工作。
管道只能用于相关进程之间的通信这个说法存在一种例外情况。通过UNIX domain socket(在61.13.3节中将会简要介绍的一项技术)传递一个文件描述符使得将管道的一个文件描述符传递给一个非相关进程成为可能。
关闭未使用管道文件描述符不仅仅是为了确保进程不会耗尽其文件描述符的限制——这对于正确使用管道是非常重要的。下面介绍为何必须要关闭管道的读取端和写入端的未使用文件描述符。
从管道中读取数据的进程会关闭其持有的管道的写入描述符,这样当其他进程完成输出并关闭其写入描述符之后,读者就能够看到文件结束(在读完管道中的数据之后)。
如果读取进程没有关闭管道的写入端,那么在其他进程关闭了写入描述符之后,读者也不会看到文件结束,即使它读完了管道中的所有数据。相反,read()将会阻塞以等待数据,这是因为内核知道至少还存在一个管道的写入描述符打开着,即读取进程自己打开了这个描述符。从理论上来讲,这个进程仍然可以向管道写入数据,即使它已经被读取操作阻塞了。如read()可能hiu被一个向管道写入数据的信号处理器中断。(这是现实世界中的一种场景,读者在63.5.2节中将会看到。)
写入进程关闭其持有的管道的读取描述符是出于不同的原因。当一个进程试图向一个管道中写入数据但没有任何进程拥有该管道的打开着的读取描述符时,内核会向写入进程发送一个SIGPIPE信号。在默认情况下,这个信号会杀死一个进程。但进程可以捕获或忽略该信号,这样就会导致管道上的write()操作因EPIPE错误(已损坏的管道)而失败。收到SIGPIPE信号或得到EPIPE错误对于标示出管道的状态是有用的,这就是为何需要关闭管道的未使用读取描述符的原因。
注意:对被SIGPIPE处理器中断的write()的处理是特殊的。通常,当write()(或其他“慢”系统调用)被一个信号处理器中断时,这个调用会根据是否使用sigaction() SA_RESTART标记安装了处理器而自动重启或因EINTR错误而失败(参见21.5节)。对SIGPIPE的处理不同是因为自动重启write()或简单标示出write()被一个处理器中断了是毫无意义的(意味着需要手工重启write())。不管是何种处理方式,后续的write()都不会成功,因为管道仍然处于被损坏的状态。
如果写入进程没有关闭管道的读取端,那么即使在其他进程已经关闭了管道的读取端之后写入进程仍然能够向管道写入数据,最后写入进程会将数据充满整个管道,后续的写入请求会被永远阻塞。
关闭未使用文件描述符的最后一个原因是只有当所有进程中所有引用一个管道的文件描述符被关闭之后才会销毁该管道以及释放该管道占用的资源以供其他进程复用。此时,管道中所有未读取的数据都会丢失。
程序清单44-2中的程序演示了如何将管道用于父进程和子进程之间的通信。这个例子演示了前面提及的管道的字节流特性——父进程在一个操作中写入数据,子进程一小块一小块地从管道中读取数据。
主程序调用pipe()创建管道①,然后调用fork()创建一个子进程②。在fork()调用之后,父进程关闭了其持有的管道的读取端的文件描述符⑧并将通过程序的命令行参数传递进来的字符串写到管道的写入端⑨。父进程接着关闭管道的读取端⑩并调用wait()等待子进程终止⑪。在关闭了所持有的管道的写入端的文件描述符③之后,子进程进入了一个循环,在这个循环中从管道读取④数据块并将它们写到⑥标准输出中。当子进程碰到管道的文件结束时⑤就退出循环⑦,并写入一个结尾换行字符以及关闭所持有的管道的读取端的描述符,最后终止。
下面是运行程序清单44-2中的程序时可能看到的输出。
程序清单44-2:在父进程和子进程间使用管道通信
在24.5节中介绍了如何使用信号来同步父进程和子进程的动作以防止出现竞争条件。也可以使用管道来取得类似的结果,如程序清单44-3中给出的骨架程序所示。这个程序创建了多个子进程(每个命令行参数对应一个子进程),每个子进程都完成某个动作,在本例中则是睡眠一段时间。父进程等待直到所有子进程完成了自己的动作为止。
为了执行同步,父进程在创建子进程②之前构建了一个管道①。每个子进程会继承管道的写入端的文件描述符并在完成动作之后关闭这些描述符③。当所有子进程都关闭了管道的写入端的文件描述符之后,父进程在管道上的read()⑤就会结束并返回文件结束(0)。这时,父进程就能够做其他工作了。(注意在父进程中关闭管道的未使用写入端④对于这项技术的正常运转是至关重要的,否则父进程在试图从管道中读取数据时会被永远阻塞。)
下面是使用程序清单44-3中的程序创建三个分别睡眠4、2和6秒的子进程时所看到的输出。
程序清单44-3:使用管道同步多个进程
与前面使用信号来同步相比,使用管道同步具备一个优势:它可以同来协调一个进程的动作使之与多个其他(相关)进程匹配。而多个(标准)信号无法排队的事实使得信号不适用于这种情形。(相反,信号的优势是它可以被一个进程广播到进程组中的所有成员处。)
其他同步结构也是可行的(如使用多个管道)。此外,还可以对这项技术进行扩展,即不关闭管道,每个子进程向管道写入一条包含其进程ID和一些状态信息的消息。或者每个子进程可以向管道写入一个字节。父进程可以计数和分析这些消息。这种方法考虑到了子进程意外终止而不是显式地关闭管道的情形。
当管道被创建之后,为管道的两端分配的文件描述符是可用描述符中数值最小的两个。由于在通常情况下,进程已经使用了描述符0、1和2,因此会为管道分配一些数值更大的描述符。那么如何形成图44-1中给出的情形呢,使用管道连接两个过滤器(即从stdin读取和写入到stdout的程序)使得一个程序的标准输出被定向到管道中,而另一个程序的标准输入则从管道中读取?特别是如何在不修改过滤器本身的代码的情况下完成这项工作呢?
这个问题的答案是使用在5.5节中介绍的技术,即复制文件描述符。一般来讲会使用下面的系列调用来获得预期的结果。
上面这些调用的最终结果是进程的标准输出被绑定到了管道的写入端。而对应的一组调用可以用来将进程的标准输入绑定到管道的读取端上。
注意,上面这些调用假设已经为进程打开了文件描述符0、1和2。(shell通常能够确保为它执行的每个程序都打开了这三个描述符。)如果在执行上面的调用之前文件描述符0已经被关闭了,那么就会错误地将进程的标准输入绑定到管道的写入端上。为避免这种情况的发生,可以使用dup2()调用来取代对close()和dup()的调用,因为通过这个函数可以显式地指定被绑定到管道一端的描述符。
在复制完pfd[1]之后就拥有两个引用管道的写入端的文件描述符了:描述符1和pfd[1]。由于未使用的管道文件描述符应该被关闭,因此在dup2()调用之后需要关闭多余的描述符。
前面给出的代码依赖于标准输出在之前已经被打开这个事实。假设在pipe()调用之前,标准输入和标准输出都被关闭了。那么在这种情况下,pipe()就会给管道分配这两个描述符,即pfd[0]的值可能为0,pfd[1]的值可能为1。其结果是前面的dup2()和close()调用将下面的代码等价。
因此按照防御性编程实践的要求最好将这些调用放在一个if语句中,如下所示。
程序清单44-4使用本节介绍的技术实现了图44-1中给出的结构。在构建完一个管道之后,这个程序创建了两个子进程。第一个子进程将其标准输出绑定到管道的写入端,然后执行ls。第二个子进程将其标准输入绑定到管道的写入端,然后执行wc。
程序清单44-4:使用管道连接ls和wc
当执行程序清单44-4中的程序时会看到下面的输出。
管道的一个常见用途是执行shell命令并读取其输出或向其发送一些输入。popen()和pclose()函数简化了这个任务。
popen()函数创建了一个管道,然后创建了一个子进程来执行shell,而shell又创建了一个子进程来执行command字符串。mode参数是一个字符串,它确定调用进程是从管道中读取数据(mode是r)还是将数据写入到管道中(mode是w)。(由于管道是单向的,因此无法在执行的command中进行双向通信。)mode的取值确定了所执行的命令的标准输出是连接到管道的写入端还是将其标准输入连接到管道的读取端,如图44-4所示。
图44-4:进程关系是popen()中管道的使用概述
popen()在成功时会返回可供stdio库函数使用的文件流指针。当发生错误时(如mode不是r或w,创建管道失败,或通过fork()创建子进程失败),popen()会返回NULL并设置errno以标示出发生错误的原因。
在popen()调用之后,调用进程使用管道来读取command的输出或使用管道向其发送输入。与使用pipe()创建的管道一样,当从管道中读取数据时,调用进程在command关闭管道的写入端之后会看到文件结束;当向管道写入数据时,如果command已经关闭了管道的读取端,那么调用进程会收到SIGPIPE信号并得到EPIPE错误。
一旦I/O结束之后可以使用pclose()函数关闭管道并等待子进程中的shell终止。(不应该使用fclose()函数,因为它不会等待子进程。)pclose()在成功时会返回子进程中shell的终止状态(参见26.1.3节)(即shell所执行的最后一条命令的终止状态,除非shell是被信号杀死的)。与system()(参见27.6节)一样,如果无法执行shell,那么pclose()会返回一个值就像是子进程中的shell通过调用_exit(127)来终止一样。如果发生了其他错误,那么pclose()返回−1。其中可能发生的一个错误是无法取得终止状态,本章稍后就会介绍可能会发生这种情况的原因。
当执行等待以获取子进程中shell的状态时,SUSv3要求pclose()与system()一样,即在内部的waitpid()调用被一个信号处理器中断之后自动重启该调用。
一般来讲,在27.6节中描述的有关system()的规范同样适用于popen()。使用popen()更加方便一些,它会构建管道、执行描述符复制、关闭未使用的描述符并帮助开发人员处理fork()和exec()的所有细节。此外,shell处理针对的是命令。这种便捷性所牺牲的是效率,因为至少需要创建两个额外的进程:一种用于shell,一个或多个用于shell执行的命令。与system()一样,在特权进程中永远都不应该使用popen()。
虽然system()和popen()以及pclose()之间存在很多相似之处,但也存在显著的差异。这些差异源自这样一个事实,即使用system()时shell命令的执行是被封装在单个函数调用中的,而使用popen()时,调用进程是与shell命令并行运行的,然后会调用pclose()。具体的差异包括以下两个方面。
程序清单44-5演示了popen()和pclose()的用法。这个程序重复读取一个文件名通配符模式②,然后使用popen()获取将这个模式传入ls命令之后的结果⑤。(在较早的UNIX实现上会使用类似的技术执行文件名生成任务,这种技术也被称为通配globbing,它在引入glob()库函数之前就已经存在了。)
程序清单44-5:使用popen()通配文件名模式
下面的shell会话演示了程序清单44-5中给出的程序的用法。在本例中首先提供了一个匹配两个文件名的模式,然后又给出了一个与任何文件名都不匹配的模式。
这里需要对程序清单44-5中通配命令的构建①④稍微解释一下。真正执行模式匹配的是shell。ls命令仅仅用来列出匹配的文件名,每一个行列出一个。读者可以尝试使用echo命令,但当模式与所有文件名都不匹配时这种做法会出现非预期的结果,然后shell就会保持模式不变,而echo会简单地打印出模式。相反,如果传递给ls的文件名不存在,那么它就会在stderr(通过将stderr重定向到/dev/null来丢弃写入这个描述符中的数据)上打印出一条错误消息,而不会在stdout上打印出任何消息,并且最后的退出状态为1。
还需要注意程序清单44-5中程序所做的输入检测③。之所以这样做是为了防止非法输入引起popen()执行一个预期之外的shell命令。假设忽略了这些检测,并且用户输入了下面的输入。
程序会将下面的命令传递给popen(),其结果是损失惨重。
在使用popen()(或system())执行根据用户输入构建的shell命令的程序中永远都需要做输入检测。(应用程序可以选择另一种方法,即将那些无需检测的字符放在引号中,这样shell就不会对那些字符进行特殊处理了。)
由于popen()调用返回的文件流指针没有引用一个终端,因此stdio库会对这种文件流应用块缓冲(参见13.2节)。这意味着当将mode的值设置为w来调用popen()时,在默认情况下只有当stdio缓冲器被充满或使用pclose()关闭了管道之后输出才会被发送到管道另一端的子进程。在很多情况下,这种处理方式是不存在问题的。但如果需要确保子进程能够立即从管道中接收数据,那么就需要定期调用fflush()或使用setbuf(fp, NULL)调用禁用stdio缓冲。当使用pipe()系统调用创建管道,然后使用fdopen()获取一个与管道的写入端对应的stdio流时也可以使用这项技术。
如果调用popen()的进程正在从管道中读取数据(即mode是r),那么事情就不是那么简单了。在这样情况下如果子进程正在使用stdio库,那么——除非它显式地调用了fflush()或setbuf()——其输出只有在子进程填满stdio缓冲器或调用了fclose()之后才会对调用进程可用。(如果正在从使用pipe()创建的管道中读取数据并且向另一端写入数据的进程正在使用stdio库,那么同样的规则也是适用的。)如果这是一个问题,那么能采取的措施就比较有限的,除非能够修改在子进程中运行的程序的源代码使之包含对setbuf()或fflush()调用。
如果无法修改源代码,那么可以使用伪终端来替换管道。一个伪终端是一个IPC通道,对进程来讲它就像是一个终端。其结果是stdio库会逐行输出缓冲器中的数据。第64章将会介绍伪终端。
从语义上来讲,FIFO与管道类似,它们两者之间最大的差别在于FIFO在文件系统中拥有一个名称,并且其打开方式与打开一个普通文件是一样的。这样就能够将FIFO用于非相关进程之间的通信(如客户端和服务器)。
一旦打开了FIFO,就能在它上面使用与操作管道和其他文件的系统调用一样的I/O系统调用了(如read()、write()和close())。与管道一样,FIFO也有一个写入端和读取端,并且从管道中读取数据的顺序与写入的顺序是一样的。FIFO的名称也由此而来:先入先出。FIFO有时候也被称为命名管道。
与管道一样,当所有引用FIFO的描述符都被关闭之后,所有未被读取的数据会被丢弃。
使用mkfifo命令可以在shell中创建一个FIFO。
pathname是创建的FIFO的名称,–m选项用来指定权限mode,其工作方式与chmod命令一样。
当在FIFO(或管道)上调用fstat()和stat()函数时它们会在stat结构的st_mode字段中返回一个类型为S_IFIFO的文件(参见15.1节)。当使用ls –l列出文件时,FIFO文件在第一列的类型为p,ls –F会在FIFO路径名后面附加上一个管道符(|)。
mkfifo()函数创建一个名为pathname的全新的FIFO。
mode参数指定了新FIFO的权限。这些权限是通过将表15-4中的常量取OR来指定的。与往常一样,这些权限会按照进程的umask值(参见15.4.6节)来取掩码。
以前创建FIFO使用的是mknod(pathname,S_IFIFO, 0)系统调用。POSIX.1-1990规定了mkfifo(),它更加简单,并且消除了mknod()具备的通用性,这种通用性允许创建各种类型的文件,包括设备文件。(SUSv3规定了mknod(),但并没有详细规定,它只定义了这个函数的用途是创建FIFO。)大多数UNIX实现提供了mkfifo(),它是构建于mknod()之上的一个库函数。
一旦FIFO被创建,任何进程都能够打开它,只要它能够通过常规的文件权限检测(参见15.4.3节)。
打开一个FIFO具备一些不寻常的语义。一般来讲,使用FIFO时唯一明智的做法是在两端分别设置一个读取进程和一个写入进程。这样在默认情况下,打开一个FIFO以便读取数据(open() O_RDONLY标记)将会阻塞直到另一个进程打开FIFO以写入数据(open() O_WRONLY标记)为止。相应地,打开一个FIFO以写入数据将会阻塞直到另一个进程打开FIFO以读取数据为止。换句话说,打开一个FIFO会同步读取进程和写入进程。如果一个FIFO的另一端已经打开(可能是因为一对进程已经打开了FIFO的两端),那么open()调用会立即成功。
在大多数UNIX实现(包括Linux)上,当打开一个FIFO时可以通过指定O_RDWR标记来绕过打开FIFO时的阻塞行为。这样,open()就会立即返回,但无法使用返回的文件描述符在FIFO上读取和写入数据。这种做法破坏了FIFO的I/O模型,SUSv3明确指出以O_RDWR标记打开一个FIFO的结果是未知的,因此出于可移植性的原因,开发人员不应该使用这项技术。对于那些需要避免在打开FIFO时发生阻塞的需求,open()的O_NONBLOCK标记提供了一种标准化的方法来完成这个任务(参见44.9节)。
在打开一个FIFO时避免使用O_RDWR标记还有另外一个原因。当采用那种方式调用open()之后,调用进程在从返回的文件描述符中读取数据时永远都不会看到文件结束,因为永远都至少存在一个文件描述符被打开着以等待数据被写入FIFO,即进程从中读取数据的那个描述符。
shell管道线的其中一个特征是它们是线性的,管道线中的每个进程都读取前一个进程产生的数据并将数据发送到其后一个进程中。使用FIFO就能够在管道线中创建子进程,这样除了将一个进程的输出发送给管道线中的后面一个进程之外,还可以复制进程的输出并将数据发送到另一个进程中。要完成这个任务需要使用tee命令,它将其从标准输入中读取到的数据复制两份并输出:一份写入到标准输出,另一份写入到通过命令行参数指定的文件中。
将传给tee命名的file参数设置为一个FIFO可以让两个进程同时读取tee产生的两份数据。下面的shell会话演示了这种用法,它创建了一个名为myfifo的FIFO,然后在后台启动一个wc命令,该命令会打开FIFO以读取数据(这个操作会阻塞直到有进程打开FIFO写入数据为止),接着执行一条管道线将ls的输出发送给tee,tee会将输出传递给管道线中的下一个命令sort,同时还会将输出发送给名为myfifo的FIFO。(sort的–k5n选项会导致ls的输出按照第五个以空格分隔的字段的数值升序排序。)
从图表上来看,上面的命令创建了图44-5中给出的情形。
图44-5:使用FIFO和tee(1)创建双重管道线
tee程序之所以这样命名是因为其外形。可以将tee看成是功能与管道类似的一个实体,但它存在一个额外的分支发送一份输出的副本。从图表上来看,其形状像是一个大写字母T(参见图44-5)。除了上面描述的用途之外,tee对于管道线调试和保存复杂管道线中某些中间节点的输出结果也是非常有用的。
本节将介绍一个简单的使用FIFO进行IPC的客户端/服务器应用程序。服务器提供的(简单)服务是向每个发送请求的客户端赋一个唯一的顺序数字。在对这个应用程序进行讨论的过程中将会介绍与服务器设计有关的一些概念和技术。
在这个示例应用程序中,所有客户端使用一个服务器FIFO来向服务器发送请求。头文件(程序清单44-6)定义了众所周知的名称(/tmp/seqnum_sv),服务器的FIFO将使用这个名称。这个名称是固定的,因此所有客户端知道如何联系到服务器。(在这个示例应用程序中将会在/tmp目录中创建FIFO,这样在大多数系统上都能够在不修改程序的情况下方便地运行这个程序。但正如在38.7节中指出的那样,在一个像/tmp这样的公共可写的目录中创建文件可能会导致各种安全隐患,因此现实世界中的应用程序不应该使用这种目录。)
在客户端-服务器应用程序中将会不断地碰到一个概念,即服务器用来使服务对客户端可见的众所周知的地址或名称。对于客户端如何知道在何处联系服务器这个问题来讲,使用众所周知的地址是一种解决方案。另一种可能的解决方案是提供某种名称服务器,服务器可以将它们的服务的名称注册到名称服务器上。然后每个客户端联系名称服务器以获取服务的位置。这个解决方案允许灵活地配置服务器的位置,而付出的代价则是需要进行额外的编程。当然,客户端和服务器需要知道到何处联系名称服务器,它位于一个众所周知的地址。
无法使用单个FIFO向所有客户端发送响应,因为多个客户端在从FIFO中读取数据时会相互竞争,这样就可能会出现各个客户端读取到了其他客户端的响应消息,而不是自己的响应消息。因此每个客户端需要创建一个唯一的FIFO,服务器使用这个FIFO来向该客户端递送响应,并且服务器需要知道如何找出各个客户端的FIFO。解决这个问题的一种方式是让客户端生成自己的FIFO路径名,然后将路径名作为请求消息的一部分传递给服务器。或者客户端和服务器可以约定一个构建客户端FIFO路径名的规则,然后客户端可以将构建自己的路径名所需的相关信息作为请求的一部分发送给服务器。本例中将会使用后面一种解决方案。每个客户端的FIFO是从一个由包含客户端的进程ID的路径名构成的模板(CLIENT_FIFO_TEMPLATE)中构建而来的。在生成过程中包含进程ID可以很容易地产生一个对各个客户端唯一的名称。
图44-6展示了这个应用程序如何使用FIFO来完成客户端和服务器进程之间的通信。
图44-6:在单服务器、多客户端应用程序中使用FIFO
头文件(程序清单44-6)定义了客户端发送给服务器的请求消息的格式和服务器发送给客户端的响应消息的格式。
记住管道和FIFO中的数据是字节流,消息之间是没有边界的。这意味着当多条消息被递送到一个进程中时,如本例中的服务器,发送者和接收者必须要约定某种规则来分隔消息。这可以使用多种方法。
图44-7展示了这三种技术。注意不管使用这三种技术中的哪种,每条消息的总长度必须要小于PIPE_BUF字节以防止内核对消息进行拆分,从而造成与其他写者发送的消息错乱的情况的发生。
图44-7:分隔字节流中的消息
在正文描述的三种技术中,所有客户端发送的所有消息都会被放在一个通道(FIFO)中。另一种方法是为每条消息使用一个连接。发送者打开通信通道,发送消息,然后关闭通道。读取进程在碰到文件结束时就知道达到消息结尾了。如果多个写者都打开了一个FIFO,那么这种方法就不可行了,因为读取在其中一个写者关闭FIFO之后不会看到文件结束。但当使用流socket时这种方法就变得可行了,因为服务器进程会为每个进入的客户端连接创建一个唯一的通信通道。
在本章的示例应用程序中将使用上面介绍的第三种技术,即每个客户端向服务器发送的消息的长度是固定的。程序清单44-6中的request结构定义了消息。每个发送给服务器的请求都包含了客户端的进程ID,这样服务器就能够构建客户端用来接收响应的FIFO的名称了。请求中还包含了一个seqLen字段,它指定了应该为这个客户端分配的序号的数量。服务器向客户端发送的响应消息由一个字段seqNum构成,它是为这个客户端分配的一组序号的起始值。
程序清单44-6:fifo_seqnum_server.c和fifo_seqnum_client.c的头文件
程序清单44-7是服务器的代码。这个服务器按序完成了下面的工作。
这是一种迭代式服务器,这种服务器会在读取和处理完当前客户端之后才会去处理下一个客户端。当每个客户端请求的处理和响应都能够快速完成时采用这种迭代式服务器设计是合理的,因为不会对其他客户端请求的处理产生延迟。另一种设计方法是并发式服务器,在这种设计中主服务器进程使用单独的子进程(或线程)来处理各个客户端的请求。第60章将会深入介绍服务器设计。
程序清单44-7:使用FIFO的迭代式服务器
程序清单44-8是客户端的代码。客户端按序完成了下面的工作。
另一个需要注意的地方是通过atexit()③建立的退出处理器①,它确保了当进程退出之后客户端的FIFO会被删除。或者可以在客户端FIFO的open()调用之后立即调用unlink()。在那个时刻这种做法是能够正常工作的,因为它们都执行了阻塞的open()调用,服务器和客户端各自持有了FIFO的打开着的文件描述符,而从文件系统中删除FIFO名称不会对这些描述符以及它们所引用的打开着的文件描述符产生影响。
下面是运行这个客户端和服务器程序时看到的输出。
程序清单44-8:序号服务器的客户端
前面曾经提过当一个进程打开一个FIFO的一端时,如果FIFO的另一端还没有被打开,那么该进程会被阻塞。但有些时候阻塞并不是期望的行为,而这可以通过在调用open()时指定O_NONBLOCK标记来实现。
如果FIFO的另一端已经被打开,那么O_NONBLOCK对open()调用不会产生任何影响——它会像往常一样立即成功地打开FIFO。只有当FIFO的另一端还没有被打开的时候O_NONBLOCK标记才会起作用,而具体产生的影响则依赖于打开FIFO是用于读取还是用于写入的。
为读取而打开FIFO和为写入而打开FIFO时O_NONBLOCK标记所起的作用不同是有原因的。当FIFO的另一个端没有写者时打开一个FIFO以便读取数据是没有问题的,因为任何试图从FIFO读取数据的操作都不会返回任何数据。但当试图向没有读者的FIFO中写入数据时将会导致SIGPIPE信号的产生以及write()返回EPIPE错误。
表44-1对打开FIFO的语义进行了总结,包括上面介绍的O_NONBLOCK标记的作用。
表44-1:在FIFO上调用open()的语义
open()类型
|
open()的结果
|
||
---|---|---|---|
打开的目的 | 额外标记 | FIFO另一端的打开操作 | FIFO另一端的关闭操作 |
读取 | 无(阻塞) | 立即成功 | 阻塞 |
O_NONBLOCK | 立即成功 | 立即成功 | |
写入 | 无(阻塞) | 立即成功 | 阻塞 |
O_NONBLOCK | 立即成功 | 失败(ENXIO) |
在打开一个FIFO时使用O_NONBLOCK标记存在两个目的。
当两个或多个进程中每个进程都因等待对方完成某个动作而阻塞时会产生死锁。图44-8给出了两个进程发生死锁的情形。各个进程都因等待打开一个FIFO以便读取数据而阻塞。如果各个进策划那个都可以执行其第二个步骤(打开另一个FIFO以便写入数据)的话就不会发生阻塞。这个特定的死锁问题是通过颠倒进程Y中的步骤1和步骤2并保持进程X中两个步骤的顺序不变来解决,反之亦然。但在一些应用程序中进行这样的调整可能并不容易。相反,可以通过在为读取而打开FIFO时让其中一个进程或两个进程都指定O_NONBLOCK标记来解决这个问题。
图44-8:打开两个FIFO的进程之间的死锁
O_NONBLOCK标记不仅会影响open()的语义,而且还会影响——因为在打开的文件描述中这个标记仍然被设置着——后续的read()和write()调用的语义。下一节将会对这些影响进行描述。
有些时候需要修改一个已经打开的FIFO(或另一种类型的文件)的O_NONBLOCK标记的状态,具体存在这个需求的场景包括以下几种。
当碰到上面的需求时可以使用fcntl()启用或禁用打开着的文件的O_NONBLOCK状态标记。通过下面的代码(忽略的错误检查)可以启用这个标记。
通过下面的代码可以禁用这个标记。
表44-2对管道和FIFO上的read()操作进行了总结,包括O_NONBLOC标记的作用。
表44-2:从一个包含p字节的管道或FIFO中读取n字节的语义
是否启用 O_NONBLOCK
|
管道或FIFO中可用的数据字节(p)
|
|||
---|---|---|---|---|
p = 0,写入端打开 | p = 0,写入端关闭 | p < n | p >= n | |
否 | 阻塞 | 返回0(EOF) | 读取p字节 | 读取n字节 |
是 | 失败(EAGAIN) | 返回0(EOF) | 读取p字节 | 读取n字节 |
只有当没有数据并且写入端没有被打开时阻塞和非阻塞读取之间才存在差别。在这种情况下,普通的read()会被阻塞,而非阻塞read()会失败并返回EAGAIN错误。
当O_NONBLOCK标记与PIPE_BUF限制共同起作用时O_NONBLOCK标记对象管道或FIFO写入数据的影响会变得复杂。表44-3对write()的行为进行了总结。
表44-3:向一个管道或FIFO写入n字节的语义
是否启用O_NONBLOCK
|
读取端打开
|
读取端关闭
|
|
---|---|---|---|
n <= PIPE_BUF
|
n > PIPE_BUF
|
||
否 | 原子地写入n字节;可能阻塞,直到足够的数据被读取以便继续执行write() | 写入n字节;可能阻塞,直到足够的数据被读取以便结束write();数据可能会与其他进程写入的数据发生交叉 | SIGPIPE + EPIPE |
是 | 如果空间足以立即写入n字节,那么write()会原子地成功;否则就失败(EAGAIN) | 如果空间足以写入一些字节,那么写入的字节数在1到n之间(可能会与其他进程写入的数据发生交叉);否则write()会失败(EAGAIN) |
当数据无法立即被传输时O_NONBLOCK标记会导致在一个管道或FIFO上的write()失败(错误是EAGAIN)。这意味着当写入了PIPE_BUF字节之后,如果在管道或FIFO中没有足够的空间了,那么write()会失败,因为内核无法立即完成这个操作并且无法执行部分写入,否则就会破坏不超过PIPE_BUF字节的写入操作的原子性的要求。
当一次写入的数据量超过PIPE_BUF字节时,该写入操作无需是原子的。因此,write()会尽可能多地传输字节(部分写)以充满管道或FIFO。在这种情况下,从write()返回的值是实际传输的字节数,并且调用者随后必须要进行重试以写入剩余的字节。但如果管道或FIFO已经满了,从而导致哪怕连一个字节都无法传输了,那么write()会失败并返回EAGAIN错误。
管道是UNIX系统上出现的第一种IPC方法,shell以及其他应用程序经常会使用管道。管道是一个单项、容量有限的字节流,它可以用于相关进程之间的通信。尽管写入管道的数据块的大小可以是任意的,但只有那些写入的数据量不超过PIPE_BUF字节的写入操作才被确保是原子的。除了是一种IPC方法之外,管道还可以用于进程同步。
在使用管道时必须要小心地关闭未使用的描述符以确保读取进程能够检测到文件结束和写入进程能够收到SIGPIPE信号或EPIPE错误。(通常,最简单的做法是让向管道写入数据的应用程序忽略SIGPIPE并通过EPIPE错误检测管道是否“坏了”。)
popen()和pclose()函数允许一个程序向一个标准shell命令传输数据或从中读取数据,而无需处理创建管道、执行shell以及关闭未使用的文件描述符的细节。
FIFO除了mkfifo()创建和在文件系统中存在一个名称以及可以被拥有合适的权限的任意进程打开之外,其运作方式与管道完全一样。在默认情况下,为读取数据而打开一个FIFO会被阻塞直到另一个进程为写入数据而打开了该FIFO,反之亦然。
本章讨论了几个相关的主题。首先介绍了如何复制文件描述符使得一个过滤器的标准输入或输出可以被绑定到一个管道上。在介绍使用FIFO构建一个客户端-服务器的例子中介绍了几个与客户端-服务器设计相关的主题,包括为服务器使用一个众所周知的地址以及迭代式服务器设计和并发服务器设计之间的对比。在开发示例FIFO应用程序时提到尽管通过管道传输的数据是一个字节流,但有时候将数据打包成消息对于通信来讲也是有用的,并且介绍了几种将数据打包成消息的方法。
最后介绍了在打开一个FIFO并执行I/O时O_NONBLOCK标记(非阻塞I/O)的影响。O_NONBLOCK标记对于在打开FIFO时不希望阻塞来讲是有用的,同时对读取操作在没有数据可用时不阻塞或在写入操作在管道或FIFO没有足够的空间时不阻塞也是有用的。
[Bach, 1986]和[Bovet & Cesati, 2005]讨论了管道的实现。有关管道和FIFO的有用细节还可以在[Vahalia, 1996]中找到。
44-1. 编写一个程序使之使用两个管道来启用父进程和子进程之间的双向通信。父进程应该循环从标准输入中读取一个文本块并使用其中一个管道将文本发送给子进程,子进程将文本转换成大写并通过另一个管道将其传回给父进程。父进程读取从子进程过来的数据并在继续下一个循环之前将其反馈到标准输出上。
44-2. 实现popen()和pclose()。尽管这些函数因无需完成在system()实现(参见27.7节)中的信号处理而得到了简化,但需要小心地将管道两端正确绑定到各个进程的文件流上并确保关闭所有引用管道两端的未使用的描述符。由于通过多个popen()调用创建的子进程可能会同时运行,因此需要需要维护一个将popen()分配的文件流与相应的子进程ID关联起来的数据结构。(如果使用数组,那么可以将fileno()函数的返回值作为数组的下标,这个函数能够取得与一个文件流对应的文件描述符。)从这个结构中取得正确的进程ID使得pclose()能够选择需等待的子进程。这个结构还满足了SUSv3的要求,即在新的子进程中必须要关闭所有通过之前的popen()调用仍然打开着的文件流。
44-3. 程序清单44-7中的服务器(fifo_seqnum_server.c)每次在启动时都会从序号0开始赋序号值。修改程序使它使用一个在每次赋序号时都会更新的备份文件。(在4.3.1节中介绍的open() O_SYNC标记可能会有用。)在启动时,程序应该检查这个文件是否存在,如果存在的话就使用其中包含的值来初始化序号。如果在启动时没有找到备份文件,那么程序应该创建一个新文件并从0开始赋序号。(另一种方法是使用在49章中介绍的内存映射文件。)
44-4. 在程序清单44-7中的服务器(fifo_seqnum_server.c)中添加代码使它在收到SIGINT或SIGTERM信号时删除服务器FIFO并终止。
44-5. 程序清单44-7中的服务器(fifo_seqnum_server.c)在FIFO上执行第二次带O_WRONLY标记的打开操作使之在从FIFO的读取描述符(serverFd)中读取数据时永远不会看到文件结束。除了这种做法之外,还可以尝试另一种方法:当服务器在读取描述符中看到文件结束时关闭这个描述符,然后再次打开FIFO以便读取数据。(这个打开操作将会阻塞直到下一个客户端因写入而打开FIFO为止。)这种方法错在哪里了?
44-6. 程序清单44-7中的服务器(fifo_seqnum_server.c)假设客户端进程的行为是正常的。如果一个行为异常的客户端创建了一个客户端FIFO和向服务器发送了一个请求,但并没有打开其FIFO,那么服务器在打开客户端的FIFO时将会被阻塞,从而造成其他客户端的请求被无限延迟。(如果是恶意的,那么就可以认定为DoS攻击。)设计一个模型来解决这个问题,对服务器(可能还要加上程序清单44-8中的客户端)进行相应的扩展。
44-7. 编写程序验证FIFO上非阻塞打开和非阻塞I/O的操作(参见44.9节)。