1969年贝尔实验室的Ken Thompson在Digital PDP-7小型机上首次实现了UNIX系统。1973年使用C语言对UNIX进行了重写。C语言由贝尔实验室的Dennis Ritchie设计并实现的。
除了遍布于学术界的各种BSD发布版外,到20世纪80年代末商业性质的UNIX实现在各种硬件架构上都有广泛的应用,比如SunOS、Solaris、AIX等等。 每个厂商只生产一种或几种专有的计算机芯片架构,然后再销售运行于该硬件架构之上的专有操作系统。这种专有性意味着消费者转换到另一家专有操作系统和硬件平台的代价十分高昂。具备可移植性的UNIX系统的魅力逐渐开始凸显。
GNU项目(GNU’s not UNIX)的重要成果之一是制定了GNU GPL(通用公共许可协议)。以GPL许可协议发布的软件不但必须开放源码,而且应该能够在GPL条款的约束下自由对其进行重新发布。可以不受限制地修改以GPL许可协议发布的软件,但是任何经修改后发布的软件仍需遵守GPL条款。
2003年12月发布了Linux内核2.6.
C语言标准独立于任何操作系统,即C语言并不依附于UNIX系统。
POSIX是由IEEE指定的标准,符合POSIX.1标准的操作系统应向程序提供调用各项服务的API,POSIX.1文档对此进行了规范。
POSIX 1003.1-2001标准和SUSv3(Single Unix Specification)标准是一回事(大约3700页)。
UNIX标准时间表
正是由于Linux实际上几近于符合各种UNIX标准,才令其在UNIX市场上如此成功。Linux的实现和发行是分开的,多家组织(商业性的或非商业性的)都握有Linux的发行权。(各家Linux发行商所提供的只是当前稳定内核的快照)
狭义的操作系统(内核)指管理和分配计算机资源(CPU、RAM和设备)的核心层软件。虽然在没有内核的情况下计算机也能够运行程序,但有了内核会极大地简化其他程序的编写和使用。这要归功于内核为管理计算机的有限资源所提供的软件层。
内核的职责
内核态和用户态 现代处理器架构一般允许CPU至少在两种不同状态下运行:用户态、核心态(监管态)。执行硬件指令可使CPU在两种状态间来回切换。与此对应,可将虚拟内存区域划分为用户空间和内核空间部分,在用户态下运行时CPU只能访问用户空间的内存,试图访问内核空间的内存将会引发硬件异常。当运行于内核态时,CPU既能访问用户空间内存,也能访问内核空间内存。仅当处理器在核心态运行时才能执行某些特定操作,比如关闭系统、访问内存管理硬件、IO设备的初始化等等。
信号的传递和进程间通信事件的触发由内核统一协调,对进程而言随时可能发生。进程本身无法创建出新进程,也不能结束自己。进程也不能与计算机外接的输入输出设备直接通信。进程间的所有通信都要通过内核提供的通信机制来完成。
shell是一种具有特殊用户的程序,主要用于读取用户输入的命令,并执行相应的程序以响应命令。对UNIX而言shell只是一个用户进程。bash(Bourne again shell)是由GNU项目对Bourne shell的重新实现,是Linux上应用最广泛的shell。
UNIX内核维护着一套单根目录结构,这与Windows不同,后者的每个磁盘设备都有各自的目录层级。 在目录列表中普通链接是内容为“文件名+指针”的一条记录,而符号链接则是经过特殊标记的文件,内容包含了另一文件的名称。在大多数情况下只要系统调用用到了路径名,内核会自动解除该路径名中符号链接的引用,如果符号链接的目标文件自身是另一个符号链接,那么该解析过程会以递归方式重复下去,不过为了应对可能出现的循环引用,内核对解除引用的次数作了限制。正常链接也称为“硬链接”,符号链接也称为“软链接”。
应该避免以‘-’作为文件名的起始字符,因为一旦在shell命令中使用这种文件名,会被误认为命令选项。
每个进程都有一个当前工作目录,是进程解释相对路径名的参照点。进程的当前工作目录继承自其父进程。用户登录后会依据密码文件中的配置来设置当前工作目录。可以使用cd命令来改变shell的当前工作目录。
也可对目录进程权限设置,但是其意义与普通文件的权限设置不同:
UNIX系统I/O模型最为显著的特性之一是其I/O通用性概念,即同一套系统调用(open()、read()等)所执行的I/O操作可施之于所有文件类型。对于应用程序发起的I/O请求内核会将其转化为相应的文件系统或者设备驱动程序操作。 就本质而言内核只提供一种文件类型:字节流序列。 通常由shell启动的进程会继承3个已打开的文件描述符:标准输入、标准输出、标准错误。在交互式shell中上述3者一般都指向终端。
进程的内存布局
内核通过对父进程的复制来创建子进程,子进程从父进程处继承数据段、栈、堆的副本后可以修改这些内容而不影响父进程的内容。在内核中文本段被标记为只读,并由父子进程共享。execve()系统调用会销毁现有的文本段、数据段、栈、堆,并根据新程序的代码创建新段来替换它们。
可以使用_exit()系统调用或者向进程传递信号来杀死进程,无论通过何种方式退出,进程都会生成“终止状态”,即一个非负小整数,可供父进程的wait()系统调用检测。惯例是0表示成功,非0表示发生错误。
进程的用户和组标识符
特权进程指有效用户ID为0(超级用户)的进程,通常由内核所施加的权限限制对此类进程无效。
init进程 系统启动时内核会创建一个名为init的进程,即所有进程之父,该进程的程序文件为/sbin/init。系统中的所有进程不是由init创建就是由其后代创建。init进程的进程号总是1,且总是以超级用户身份运行。只有关闭系统才能终止该进程。
内存映射 mmap()系统调用会在虚拟地址空间中创建一个新的内存映射。由某一进程所映射的内存可以与其它进程的映射共享,共享实现的方式主要有2种:(1)两个进程都针对某一文件的相同部分加以映射;(2)由fork()创建的子进程从父进程中继承映射。 多个进程共享的内存页面相同时,进程之一对页面的修改其他进程是否可见取决于创建映射时所传入的标志参数。
静态库和共享库 要使用静态库中的函数需要在创建程序的链接命令中指定相应的库,主程序会对静态库中隶属于各目标模块的不同函数加以引用,链接器在解析了引用情况后会从库中抽取所需目标模块的副本将其复制到最终的可执行文件中,即所谓的静态链接。 对于共享库,链接器不会把库中的目标模块复制到可执行文件中,而是在执行文件中写入一条记录以表明可执行文件在运行时需要使用该共享库。一旦在运行时将可执行文件载入内存,动态链接器程序会确保将可执行文件所需的动态库找到。
Linux提供了丰富的进程间通信机制:
信号从产生直至送达进程期间一直处于挂起状态,系统会在接收进程下次获得调度时将处于挂起状态的信号同时送达。如果接收进程正在运行,则会立即将信号送达。
每个进程都可以执行多个线程,可将线程想象为共享同一虚拟内存及一些其他属性的进程。每个线程都会执行相同的程序代码,共享同一数据区域和堆,不过每个线程都拥有属于自己的栈用来装载本地变量和函数调用调用链接信息。线程之间可以通过共享的全局变量进行通信。显然多线程应用能够从多处理器硬件的并行处理中受益。
shell执行的每个程序都会在一个新进程内发起,比如:
ls -l | sort -k5n | less
如上的shell命令创建了3个进程来执行。
/proc文件系统是一种虚拟文件系统,以文件系统目录和文件形式提供一个指向内核数据结构的接口,为查看和改变各种系统属性提供方便。
无论何时,只要执行了系统调用或者库函数,检查调用的返回状态以确定调用是否成功,这是一条编程铁律。
系统调用是受控的内核入口。 系统调用将处理器从用户态切换到和心态,以便CPU访问受到保护的内核内存。 系统调用的组成是固定的,每个系统调用都由一个唯一的数字来标识。
**执行系统调用所发生的步骤
系统调用的执行步骤
因此从C语言编程的角度来看,调用C语言函数库的外壳函数等同于调用相应的系统调用服务例程。
使用特性测试宏、系统数据类型来处理可移植性问题,略。
与用户空间的函数调用相比哪怕是最简单的系统调用都会产生显著的开销,因为为了执行系统调用系统需要临时性地切换到核心态。此外内核还需要验证系统调用的参数、用户内存和内核内存之间也有数据需要传递。
文件描述符用于表示所有类型的已经打开的文件,包括管道、FIFO、socket、终端、设备和普通文件。
文件I/O操作的4个主要系统调用:
ioctl()系统调用为通用I/O模型之外的专有特性提供了访问接口。
在使用open()系统调用创建新文件时,新建的文件的访问权限不仅仅依赖于参数mode,而且受到进程umask值和(可能存在的)父目录的默认访问控制列表的影响。
一次read()调用所读取的字节数可以小于请求的字节数,对于普通文件而言这有可能是因为当前读取位置靠近文件尾部。当读取的是其他文件类型时,比如管道、socket、终端等,在不同环境下也会出现read()调用读取的字节数小于请求字节数的情况。例如默认情况下从终端读取字符,一旦遇到换行符(\n),read()调用就会结束。
对磁盘文件执行I/O操作时,write()调用成功并不能保证数据已经写入磁盘,因为为了减少磁盘活动量和加快write()系统调用,内核会缓存磁盘的I/O操作。
文件描述符属于有限资源,因此文件描述符关闭失败可能会导致一个进程将文件描述符资源消耗殆尽。
当进程终止时会自动关闭其已打开的所有文件描述符。
对于每个打开的文件,系统内核会记录其当前的文件偏移量,即下一个read()和write()操作的起始位置。
lseek()调用只是调整内核中与文件描述符相关的文件偏移量记录,并没有引起任何对物理设备的访问。
不能将lseek()应用于管道、socket、终端等。
文件空洞 write()函数可以在文件结尾后的任意位置写入数据。从文件结尾后到新写入数据间的这段空间称为文件空洞。从编程角度看文件空洞中是存在字节的,读取空洞将返回以0填充的缓冲区。然而文件空洞不占用任何磁盘空间,直到后续某个时刻在文件空洞中写入了数据,文件系统才会为其分配磁盘块。文件空洞的主要优势在于:与为实际需要的空字节分配磁盘块相比,稀疏填充的文件会占用较少的磁盘空间。 在大多数文件系统中文件的空间是以块为单位进行分配的,块的大小通常为1024字节、2048字节等。如果空洞的边界落在块内,而非恰好落在块边界上,则会分配一个完整的块来存储数据,块中与空洞相关的部分则以空字节填充。 空洞的存在意味着一个文件名义上的大小可能要比其占用的磁盘存储空间要大。向空洞中写入字节,内核需要为其分配存储单元。
所有系统调用都是以原子操作方式执行的,其间不会为其他进程或者线程所中断。
多个文件描述符可能指向同一个打开的文件,且这些文件描述符可在相同或者不同的进程中打开。
对于文件描述符与打开的文件之间的关系,内核维护了3个数据结构:
两个不同的文件描述符若指向同一个打开的文件句柄,将共享同一文件偏移量,即当通过其中一个文件描述符修改了文件的偏移量,从另一个文件描述符中将会观察到这一变化。
文件描述符、打开的文件句柄和i-node之间的关系
pread()和pwrite()完成与read()、write()相类似的工作,只是前两者会在offset参数指定的位置进行文件I/O操作,而非始于文件的当前偏移量处,且它们不会改变文件的当前偏移量。 当调用pread()和pwrite()时,多个线程可以同时对同一文件描述符执行I/O操作,且不会因为其他线程修改文件偏移量而受到影响。
分散输入和集中输出 readv()和writev()系统调用分别实现了分散输入和集中输出的功能,它们并非只对单个缓冲区进行读写操作,而是一次即可传输多个缓冲区的数据。 readv()从文件描述符中读取一片连续的字节,然后将其散置于一组缓冲区中,最后一个缓冲区中可能只有部分数据。 writev()将一组缓冲区中的所有数据拼接起来,然后以连续的字节序列写入文件描述符指定的文件中。
对于每个进程内核都提供了一个特殊的虚拟目录/dev/fd,该目录包含/dev/fd/n形式的文件名,其中n是进程中打开的文件描述符相对应的编号。例如/dev/fd/0就对应于进程的标准输入。
打开/dev/fd目录中的一个文件等同于复制相应的文件描述符,所以如下两行代码是等价的:
fd = open("/dev/fd/1", O_WRONLY); fd = dup(1);
/dev/fd实际上是一个符号链接,链接到Linux所专有的/proc/self/fd目录,后者是Linux特有的/proc/PID/fd目录族是
程序是包含了一系列信息的文件,这些信息描述了如何在运行时创建一个进程,主要包括以下内容:
进程是由内核定义的抽象实体,并为该实体分配用以执行程序的各项系统资源。从内核角度看,进程由用户内存空间和一系列内核数据结构组成,其中用户内存空间包含了程序代码及代码所使用的变量,而内核数据结构则用于维护进程状态信息。记录在内核数据结构中的信息包括许多与进程相关的标识号、虚拟内存表、打开的文件描述符表、信号传递及处理的有关信息、进程资源使用及限制、当前工作目录和大量的其他信息。
Linux内核限制进程号需小于等于32767,新进程创建时内核会按顺序将下一个可用的进程号分配给其使用,一旦进程号达到32767,会将进程号计数器重置为300,而不是1,因为低数值的进程号为系统进程和守护进程所长期占用,在此范围内搜索尚未使用的进程号是浪费时间。
在Linux 2.6中可以通过修改/proc/sys/kernel/pid_max文件来调整进程号上限。
使用pstree命令可以查看系统当前的进程家族树。
如果子进程的父进程终止,则子进程就会变成孤儿,init进程随即会收养该进程。该子进程随后对getpid()的调用将返回1.
每个进程所分配的内存由很多部分组成,通常称之为段(segment):
size命令可以显示二进制可执行文件的文本段、初始换数据段、BSS段的大小。
在Linux/x86-32中典型的进程内存结构:
大多数程序都展现了两种类型的局部性:
虚拟内存将每个程序使用的内存切割为小型的、固定大小的页单元,相应地将RAM划分为一系列与虚拟内存页尺寸相同的页帧。任何时刻每个程序仅有部分页需要驻留于物理内存页帧中,这些页构成了所谓的驻留集,程序未使用的页拷贝保存在交换区中(磁盘空间中的保留区域),仅在需要时才会载入物理内存。
内核为每个进程维护一张页表,描述了每页在进程虚拟地址空间中的位置,页表中每个条目要么指出一个虚拟页面再RAM中的位置,要么表明其当前驻留在磁盘上。虚拟内存的实现需要硬件中的分页内存管理单元(PMMU)的支持,PMMU把要访问的每个虚拟内存地址转换为相应的物理内存地址,当特定的虚拟内存地址所对应的页没有驻留于RAM中时,将以页面错误通知内核。
虚拟内存概览
虚拟内存管理使得进程的虚拟地址空间与RAM物理地址空间隔离开来,这带来许多优点:
专用寄存器“栈指针”用于跟踪当前的栈顶,每次调用函数时会在栈上新分配一帧,每当函数返回时再从栈上将此栈帧移去。
内核栈不同于用户栈(即用户进程虚拟内存空间中的栈),是每个进程保留在内核内存中的内存区域,在执行系统调用的过程中供内核的内部函数调用使用。
每个用户栈帧主要包含如下信息:
新进程在创建时会继承其父进程的环境副本,这是一种原始的进程间通信的方式。常见的用途是在shell中通过在自身环境中放置变量值,shell可以确保把这些值传递给其所创建的进程,并以此来执行用户命令。此后这个shell所创建的所有子进程都将继承此环境。
可以通过声明main函数的第三个参数来访问环境列表:
int main(int argc, char *argv[], char *envp[])
使用库函数setjmp()和longjmp()可以执行非局部跳转,即跳转的目标为当前执行函数之外的某个位置。(C语言的goto语句不能从当前函数跳到另一个函数,因为编译器无法知道当调用Y时,X函数的栈帧是否在栈上)
优化编译器会重组程序的指令执行顺序,并在CPU的寄存器中,而非RAM中存储某些变量。将变量申明为volatile是告诉优化器不要对其进行优化。
应该尽可能避免使用setjmp()和longjmp()函数。
内核会在进程首次试图访问虚拟内存地址时自动分配新的物理内存页。
通常将堆的当前内存边界称为“program break”,改变堆的大小(分配或者释放内存)其实就像命令内核改变进程的program break位置一样简单。传统的UNIX内核提供了两个操纵program break的系统调用:brk()和sbrk()。
一般情况下free()并不降低program Break的位置,而是将这块内存添加到空闲的内存列表中供后续的malloc()函数循环使用。仅当堆顶空闲内存足够大的时候,free()函数的glibc实现才会调用sbrk()来降低program break的地址。
通常情况下当增大已分配内存时,realloc()会试图去合并在空闲列表中紧随其后且大小满足要求的内存块。若原内存块位于堆的顶部,那么realloc()会对堆进行扩展,如果原内存块位于堆的中部,且紧邻其后的空闲内存空间大小不足,realloc()会分配一块新内存,并将原有数据复制到新内存块中,这将占用大量的CPU资源,一般情况下应该避免使用realloc()。
用户可以隶属于一个或多个组,每个文件都属于某个特定的用户和组,而每个进程也拥有相应的用户ID和组ID,这就决定了进程的所有者以及进程访问文件时所拥有的权限。
系统的每个用户账户都在/etc/passwd文件中有一条记录,例如:
mtk:x:1000:100:Michael Kerrisk:/home/mkt:/bin/bash
7个字段的意义如下:
之所以设计/etc/shadow文件,其理念是用户的所有非敏感信息存放于人人可读的密码文件中(/etc/passwd),而经过加密处理的密码则由shadow密码文件单独维护,仅供具有特权的程序读取。
对用户所属组的定义由两部分组成(历史原因):
UNIX系统采用单向加密算法对密码进行加密,加密算法封装于crypt()函数中(DES算法的变体)。
读取密码的程序在读取后应该立即加密密码,并尽快将密码的明文从内存中抹去,以杜绝恶意程序借程序崩溃之机读取内核转储文件以获取密码。此外,如果包含密码的虚拟内存页执行了换出操作,那么特权级程序就能从交换文件中读取密码。另外,拥有足够权限的进程可以通过读取/dev/mem(虚拟设备之一,将计算机物理内存表示为有序字节流)来尝试发现密码。
实际用户ID和实际组ID确定了进程所属的用户和组。 当进程尝试执行各种操作(即系统调用)时,将结合有效用户ID、有效组ID,连同辅助组ID一起来确定授予进程的权限。 有效用户ID为0的进程拥有超级用户的所有权限,这样的进程也称为特权级进程。某些系统调用只能由特权级进程执行。 通常有效ID与实际ID相等,可以通过系统调用或者执行set-user-ID、set-group-ID程序来修改。
set-user-ID程序会将进程的有效用户ID设置为可执行文件的属主用户ID,从而获得常规情况下并不具有的权限。区别于一般文件,可执行文件还拥有两个特别的权限位set-user-ID位和set-group-ID位,当设置了这两个位时,相应的文件权限位显示为s:
$ su Password: # ls -l prog -rwxr-xr-x 1 root root ... # chmod u+s prog # chmod g+s prog # ls -l prog -rwsr-sr-x 1 root root ...
saved set-user-ID和saved set-group-ID意在与set-user-ID和set-group-ID程序结合使用,当执行程序时将会发生如下事件:
在Linux系统中要进行诸如打开文件、改变文件属主、修改文件权限之类的文件系统操作时,决定其操作权限的是文件系统用户ID和组ID,而不是有效用户ID和组ID。通常文件系统用户ID和组ID的值等同于相应的有效用户或组ID(跟随其变化而变化),只有当使用Linux特有的两个系统调用setfsuid()和setfsgid()时,才会制造出文件系统ID与相应有效ID的不同。 从严格意义上讲,保留文件系统ID特性已无必要。
辅助组ID用于标识进程所属的若干附加的组,新进程从其父进程处继承这些ID,登录shell从系统组文件中获取其辅助的组ID。将这些ID与有效ID以及文件系统ID相结合就能决定对文件、System V IPC对象和其他系统资源的访问权限。
可以利用Linux系统特有的proc/PID/status文件,通过对其中Uid、Gid等信息的检查来获取任何进程的凭证。
当非特权进程调用setuid()时,仅能修改进程的有效用户ID,而且仅能将有效用户ID修改成相应的实际用户ID或saved set-user-ID。一旦特权进程修改了其ID,那么所有特权都将丢失,且之后不能再使用setuid()将有效用户ID重置为0。
有效用户ID为0的进程属于特权级进程,对该进程发起的各种系统调用可免于接受通常所要经历的诸多权限检查。
1970年零点,UNIX系统问世的大致时间。 I18N即internationalization,即I加上18个字母再加上N。
当在程序中指定要使用的地区时,实际上是指定了/usr/share/locale下某个子目录的名称。地区设置将影响众多GNU/Linux实用程序,以及glibc的许多函数的功能。
Linux/x86-32以每2000秒变化1秒的频率调整时钟。
事件相关的各种系统调用的精度受限于系统软件时钟(software clock)的分辨率,其度量单位为jiffiles,是定义在内核源码中的常量,也是CPU分配进程时间的单位。Linux/x86-32的软件时钟速度已经达到1000HZ,但是并非可以任意提高时钟频率,因为每个时钟中断会消耗少量CPU时间,这部分时间CPU无法执行任何操作。
进程时间是进程创建后使用的CPU时间数量,内核把CPU时间分为两部分:
系统的限制和选项有可能不同,取决于具体的UNIX实现、特定的运行环境、文件系统等。
系统限制的最小值定义在文件中。
SUSv3限制 略。
在shell中可以使用getconf命令来获取特定的限制的值。
SUSv3要求,针对特定限制,调用sysconf()系统调用所获取的值在调用进程的生命周期内应该保持不变。
为了提供更为简便的方法来访问内核信息,许多现代UNIX实现都提供了一个/proc虚拟文件系统,其中包含了用于展示各类内核信息的文件,并允许进程通过常规文件I/O系统调用来方便地读取,甚至修改、之所以将/proc文件系统称为虚拟,是因为其包含的文件和子目录并未存储于磁盘上,而是由内核在进程访问此类信息时动态地创建而成。
/proc/PID目录下的主要文件:
为了方便起见,任何进程都可以使用符号链接/proc/self来访问自己的/proc/PID目录。
/proc目录下文件和子目录的节选
除了/proc/PID子目录中的文件,/proc目录的其他大多数文件都属于root用户,只有root用户能够修改那些可修改的文件。
read()和write()系统调用在操作磁盘文件时不会直接发起磁盘访问,而是仅仅在用户空间缓冲区与内核缓冲区高速缓存之间复制数据。例如:
write(fd,"abc",3);
write()随即返回。在后续的某个时刻内核会将其缓冲区中的数据写入(刷新至)磁盘,因此可以说系统调用与磁盘操作并不同步。如果在此期间另一进程试图读取文件的这几个字节,内核将自动从缓冲区高速缓存中提供这些数据,而不是从文件中(读取过期的内容)。
总之,如果与文件发生大量的数据传输,通过采用大块空间缓冲数据,以及执行更少的系统调用,可以极大地提高I/O性能。(同样是往磁盘写入1000个字节,每次写1个字节肯定不如一次写1000个字节)
使用stdio库可以使编程者免于自行处理对数据的缓冲,无论是调用write()来输出还是调用read()来输入。
可以通过调用setvbuf()时设置mode为_IONBF从而实现不对I/O进行缓冲,stderr默认属于这一类型。
若内容发生变化的内核缓冲区在30秒内未经显式方式同步到磁盘上,则一条长期运行的内核线程会确保将其刷新到磁盘上,这一做法是为了规避缓冲区与相关磁盘文件内容长期处于不一致状态。在Linux2.6中该任务由pdflush内核线程执行。
I/O缓冲小结
Linux允许应用程序在执行磁盘I/O时绕过缓冲区高速缓存,从用户空间直接将数据传递到文件或者磁盘设备,称为直接I/O或者裸I/O。对于大多数应用而言,使用直接I/O可能会大大降低性能。直接I/O只适用于有特定I/O需求的应用,例如数据库系统,其高速缓存和I/O优化机制均自成一体,无需内核消耗CPU时间和内存去完成相同任务。
在内核中每种设备类型都有与之相对应的设备驱动程序,用来处理设备的所有I/O请求。每个设备驱动程序所提供的接口一致,从而隐藏了每个设备在操作方面的差异,满足了I/O操作的通用性。
可将设备划分为以下两种类型:
每个设备文件都有主、辅ID号各一,主ID号标识一般的设备等级,内核会使用主ID号查找与该类设备相应的驱动程序,辅ID号能够在一般等级中唯一标识特定设备。设备文件的i-node节点中记录了设备文件的主、辅ID,每个设备驱动程序都会将自己与特定主设备号的关联关系向内核注册,从而建立设备专用文件与设备驱动程序之间的关系(内核是不会使用设备文件名来查找驱动程序的)。
文件系统是对常规文件和目录的组织集合,Linux的强项之一便是支持种类繁多的文件系统。
磁盘分区和文件系统布局
文件系统由以下几部分组成:
文件的i节点所维护的信息如下:
类似于大多数UNIX文件系统,ext2文件系统在存储文件时数据块不一定连续,甚至不一定按顺序存放。为了定位文件数据块,内核在i节点内维护一组指针。 ext2文件系统中文件的文件块结构
每个i节点包含15个指针,其中前12个指针指向文件前12个块在文件系统中的位置。接下来是一个指向指针块的指针,提供了文件的第13个以及后续数据块的位置。指针块中指针的数量取决于文件系统中块的大小,每个指针需占用4字节因此指针的数量可能在256(块容量1024字节)~1024(块容量4096字节)之间,这样就考虑到了大型文件的情况。即便是对于巨型文件,第14个指针是一个双重间接指针,指向指针块,其块中指针进而指向指针块,此块中指针最终才指向文件的数据块。只要有体量巨大的文件,就会随之产生更深一层的递进。
这一设计的好处在于:
虚拟文件系统是一种内核特性,通过为文件系统操作创建抽象层来屏蔽不同文件系统的差异。
对于ext2文件系统,在系统崩溃后为确保文件系统的完整性,重启时必须对文件系统的一致性进行检查,这将遍历整个文件系统。采用日志文件系统则无需在系统崩溃后对文件进行漫长的一致性检查。日志文件系统能够确保总是将文件元数据事务作为一个完整单元来提交。系统崩溃之后,即使是超大型的日志文件系统通常也会在几秒之内复原。其缺陷在于增加了文件的更新时间。某些日志文件系统只会确保文件元数据的一致性,由于不记录文件数据,因此一旦系统崩溃可能造成数据丢失。
无法卸载正在使用中的文件系统,即这一文件系统上有文件被打开,或者进程的当前工作目录驻留在此文件系统下。
可以将一个文件系统挂载于文件系统内的多个位置,由于每个挂载点下的目录子树内容都相同,在一个挂载点下对目录子树所做的修改同样可见诸于其他挂载点。Linux允许针对同一挂载点执行多次挂载,每次新挂载都会隐藏之前可见于挂载点下的目录子树。卸载最后一次挂载时,挂载点下上次挂载的内容会再次显示。
Linux同样支持驻留于内存中的虚拟文件系统,比如tmpfs。该文件系统不但使用RAM,而且在RAM耗尽的情况下还会利用交换空间。一旦卸载tmpfs文件系统,或者遭遇系统崩溃,那么该文件系统中的所有数据都将丢失。
许多原生UNIX和Linux文件系统都支持为超级用户预留一部分文件系统块,这样即便在文件系统空间耗尽的情况下,超级用户仍可以登录系统解决故障。
设备都由/dev下的文件来表示,既可以是实际存在的,也可以是虚拟的。
利用系统调用stat()可以获取与文件有关的信息,其中大部分提取自文件的i节点。
chmod命令将会更新文件的上次修改时间,cat命令将会更新文件的上次访问时间。
要执行脚本文件需要同时具备读权限和执行权限。
目录的权限含义:
一旦调用open()打开了文件,针对返回描述符的后续系统调用(read()、write()、fstat()、fcntl()、mmap())将不再进行任何权限检查。
检查文件权限时,内核遵循如下的规则:
文件模式创建掩码umask是一种进程属性,当进程新建文件或目录时,该属性用于指明应屏蔽哪些权限位。进程的umask通常继承自其父shell,用户可以使用shell的内置命令umask来改变shell进程的umask,从而控制在shell下运行程序的umask。大多数shell的初始化文件会将umask默认设置为022(—-w–w-),即对于同组或其他用户,应该总是屏蔽写权限。
文件的扩展属性(EA)即以名值对的形式将任意元数据与文件i节点关联起来的技术。可用于实现访问列表和文件能力。EA需要有底层文件系统来提供支撑。 详略。
许多UNIX系统对传统的UNIX文件权限模型进行了名为访问控制列表(ACL)的扩展,利用ACL可以在任意数量的用户和组之中为单个用户或组指定文件权限。一个ACL由一系列ACL记录组成,其中每条记录都针对单个用户或用户组定义了对文件的访问权限。 详略。
目录与普通文件的区别有二:
以文件/etc/passwd为例展示i-node和目录结构之间的关系
i-node中并没有存储文件名,而是通过目录列表内的一个映射来定义文件名称,好处在于可以在相同或者不同目录中创建多个名称,每个均指向相同的i-node节点,也将这些名称称为硬链接。
可以利用ln命令为一个业已存在的文件创建新的硬链接:
$ echo -n 'test,' > abc $ ls -li abc 122232 -rw-r--r-- 1 mtk users 29 Jun 15 17:07 abc $ ln abc xyz $ echo 'ok' >> xyz $ cat abc test,ok $ ls -li abc xyz 122232 -rw-r--r-- 2 mtk users 29 Jun 15 17:07 abc 122232 -rw-r--r-- 2 mtk users 29 Jun 15 17:07 xyz
可见名称abc和xyz指向相同的i-node条目,因此指向相同的文件。其中ls -li展示的第1列为i-node编号,第3列为i-node链接的计数,执行ln操作后计数提升为2。 若移除其中一个文件名,另一个文件名以及文件本身将继续存在:
$ rm abc $ ls -li xyz 122232 -rw-r--r-- 1 mtk users 29 Jun 15 17:07 xyz
仅当i-node的链接计数降为0时才会删除文件的i-node记录和数据块。 无法通过文件描述符来查找关联的文件名,因为一个文件描述符指向一个i-node,而指向这个i-node的文件名则可能有多个。
对硬链接的限制有二,均可以用符号链接来加以规避:
符号链接,也称软链接,是一种特殊的文件类型,其数据是另一文件的名称。文件的链接计数中并未将符号链接计算在内,因此如果移除了符号链接所指向的文件名,符号链接本身还将继续存在,尽管无法再对其进行解引用操作,此类链接也称为悬空链接(甚至可以为并不存在的文件名创建一个符号链接)。
对硬链接和符号链接的展现
因为符号链接指代一个文件名,而非i-node编号,所以可以用其来链接不同文件系统中的文件。
大部分操作会无视符号链接的所有权和权限,仅当在带有粘性权限位的目录中对符号链接进行移除或改名操作时才会考虑符号链接自身的所有权。
当移除指向文件的最后一个链接时,如果仍有进程持有指代该文件的打开文件描述符,那么在关闭所有此类描述符之前,系统实际上将不会删除该文件。
每个进程都有一个根目录,该目录是解释绝对路径时的起点,默认情况下这是文件系统的真实根目录。特权进程可以通过chroot()系统调用来改变一个进程的根目录,这会将应用程序限定于文件系统的特定区域,因此也称为设立了一个chroot监禁区。ftp程序就是应用chroot()的典型实例:当匿名用户登录ftp时,ftp程序将使用chroot()为新进程设置根目录,即设置为一个专门预留给匿名用户的目录,用户将受困于文件系统中新根目录下的子树中。
并非任何程序都能在chroot监禁区中运行,因为大多数程序与共享库之间采取的是动态链接方式。因此,要么只能局限于运行静态链接程序,要么就在监禁区中复制一套标准的共享库系统目录。
通常最好不要在chroot监禁区文件系统内放置set-user-ID-root程序,此外必须关闭所有指向监禁区外目录的文件描述符(遭到监禁的进程仍然能够利用UNIX套接字来接受另一进程指向监禁区之外目录的文件描述符)。
i-node中并不包含文件的名称,对文件的命名利用的是目录条目,而目录则是列出文件名和i-node编号之间对应关系的一个表格,也将这些目录条目称为硬链接。
符号链接只是一个内容包含了另一个文件名称的文件。
自内核2.6.13起,Linux提供inotify机制,以允许应用程序监控文件事件(打开、关闭、创建、删除、修改、重命名等)。关键步骤如下:
可以使用select()、poll()、epoll()以及由信号驱动的I/O来监控inotify文件描述符,只要有事件可供读取,上述API就会将inotify文件描述符标记为可读。
信号是事件发生时对进程的通知机制,也称为软件中断。引发内核为进程产生信号的各类事件如下:
针对每个信号都定义了一个唯一的整数,以SIGxxxx形式的符号名对这些整数做了定义。
信号分为两大类:
在产生和到达期间,信号处于等待(pending)状态。信号到达后进程视具体信号执行如下可选操作:
Linux标准信息,略。
信号处理器程序是由程序员编写的函数,用于为响应传递来的信号而执行适当任务。调用信号处理器程序可能会随时打断主程序流程,内核代表进程来调用处理器程序,当处理器返回时,主程序会在处理器打断的位置恢复执行。
为SIGINT信号安装一个处理器程序
#include
现实世界中的应用程序一般不应该在信号处理器程序中使用stdio函数。
与kill命令类似,一个进程能够使用kill()系统调用向另一个进程发送信号。根据其参数pid的不同代表不同的意义,详略。
进程要发送信号给另一个进程,还需要适当的权限:
内核会为每个进程维护一个信号掩码,即一组信号,并将阻塞其针对该进程的传递。如果将遭阻塞的信号发送给某进程,那么对该信号的传递将延后,直至从进程信号掩码中移除该信号,从而解除阻塞为止。等待信号集只是一个掩码,仅表明一个信号是否发生,而未表明其发生的次数,所以如果同一个信号在阻塞状态下产生多次,那么会将该信号记录在等待信号集中,并在稍后仅传递一次。
信号是发生某种事件的通知机制,可以由内核、另一进程或者进程自身发送给进程,存在一系列的标准信号类型,每种都有唯一的编号和目的。信号传递通常是异步行为,意味着信号中断进程执行的位置是不可预测的。
一般情况下信号处理器函数应该设计得越简单越好,这将降低引发竞争条件的风险。
无法对信号的产生次数进行可靠计数,在为信号处理器函数编码时需要考虑处理同类信号多次产生的情况。
在信号处理器函数中并非所有系统调用以及库函数都可以安全调用。因为信号处理器函数可能会在任一时间点异步中断程序的执行,从而在同一个进程中实际形成两条(即主程序和信号处理函数)独立(虽然不是并发的)的执行线程。如果同一个进程的多条线程可以同时安全地调用某一函数,那么该函数就是可重入的,安全指无论其他线程调用该函数的执行状态如何,函数均可以产生预期结果。
更新全局变量或者静态数据结构的函数可能是不可重入的。
malloc()函数族以及使用它们的其他库函数都是不可重入的。如果信号处理器函数用到了这类函数,那么将会覆盖主程序中上次调用同一函数所返回的信息。
异步信号安全的函数使指当从信号处理器函数调用时,可以保证其实现是安全的。如果某一个函数是可重入的,又或者信号处理器函数无法将其中断时,就称该函数是异步信号安全的。
对全局变量的读写可能不止一条机器指令,而信号处理器函数就可能会在这些指令序列之间将主程序中断,因此C语言标准以及SUSv3定义了一种整型数据类型sig_atomic_t,意在保证读写操作的原子性。C语言的++和–操作符并不在受保护的范围内。
特定信号(比如Control-\)会引发进程创建一个核心转储文件,并终止运行。核心转储文件是内含进程终止时内存映像的一个文件,将该内存映像加载到调试器中即可以查明信号到达时程序代码和数据的状态。
同步产生的信号会立即传递,例如硬件异常会触发一个即时信号,而当应用程序使用raise()向自身发送信号时,信号会在raise()调用返回前就已经发出。当异步产生一个信号时,即使并未将其阻塞,在信号产生和实际传递之间仍可能会存在一个延迟,在此期间信号处于等待状态。这是因为内核将等待信号传递给进程的时机是该进程正在执行,且发生由内核态到用户态的下一次切换时,即:
定义于POSIX.1b中的实时信号意在弥补对标准信号的诸多限制,较之于标准信号其优势如下:
虽然可将信号视为IPC方式之一,然而信号作为一种IPC机制却饱受限制,因为相对于其他IPC方法,对信号的编程既繁且难(可重入、竞争条件、保证通知不丢失等等)。
Linux中alarm(一次性定时器)和setitimer(间隔式定时器)针对同一进程共享同一实时定时器。
定时器的用途之一是为系统调用的阻塞设定时间上限。
4个重要的系统调用:
概述函数fork()、exit()、wait()、execve()的协同使用
理解fork()的诀窍是要意识到完成对其调用后将存在两个进程,且每个进程都会从fork()的返回处继续执行。这两个进程将执行相同的程序文本段,但却拥有各自的栈段、数据段以及堆段拷贝。程序代码可以通过fork()的返回值来区分父、子进程。调用fork()后系统率先执行哪个进程是无法确定的。
执行fork()时,子进程会获得父进程所有的文件描述符的副本。对于shell来说,shell创建子进程后会调用wait()来暂停运行,并等待子进程退出,只有当执行命令的子进程退出后,shell才会打印自己的提示符。
虽然从概念上可以将fork()理解为对父进程程序段、数据段、堆、栈的拷贝,但实际可能并非这样。因为fork()之后常常伴随着exec(),这将会使用新程序替换进程的代码段并重新初始化其数据段、堆段、栈等。大部分现代UNIX实现采用两种技术来避免这种浪费:
对一共享写时复制页进行修改前后的页表
BSD后期版本引入了vfork()系统调用,是为子进程立即执行exec()的程序而专门设计的,其效率远高于fork()。不过现代UNIX采用写时复制技术来实现fork(),因而vfork()实际已无存在的必要。鉴于vfork()的怪异语义可能导致一些难以觉察的程序bug,除非能给性能带来重大提升,否则应当尽量避免使用。
vfork()产生的子进程将使用父进程内存,直至其调用exec()或退出,与此同时将会挂起父进程。
程序一般不会直接调用系统调用_exit(),而是调用库函数exit(),它会在执行_exit()前执行各种动作:
程序的另一种终止方法是从main()函数中返回,或者执行到main()函数的结尾处。执行return n等同于执行对exit(n)的调用,因为main()的运行时函数会将main()的返回值作为exit()的参数。
如果程序直接调用_exit()或因信号而异常终止,则不会调用退出处理程序。
通过fork()创建的子进程会继承父进程注册的退出处理函数,而进程调用exec()时,会移除所有已注册的退出处理函数。
在创建子进程的应用中典型的情况下仅有一个进程(一般为父进程)应通过调用exit()终止,而其他进程应通过调用_exit()终止,从而确保只有一个进程调用退出处理程序并刷新stdio缓冲区。
系统调用wait(status)执行如下动作:
wait()存在诸多限制,而设计waitpid()则意在突破这些限制:
waitid()提供了waitpid()所没有的扩展功能,对于应该等待的子进程事件,waitid()可以更为精确地控制:可通过在options中指定一个或多个标识符来实现这种控制。
wait3()和wait4()执行与waitpid()类似的工作,主要的语义差别在于wait3()和wait4()在参数rusage所指向的结构中返回终止子进程的资源使用情况,包括进程使用的CPU时间总量以及内存管理的统计数据。
父进程与子进程的生命周期一般都不相同,父子进程间互有长短。如果父进程先结束,子进程将变为孤儿进程,并会被init进程接管。如果在父进程wait()之前其子进程就已经终止,系统仍然允许其父进程在之后的某一时刻去执行wait()以确定该子进程是如何终止的:内核通过将该子进程转为僵尸进程来处理这种情况,意味着将释放子进程所把持的大部分资源以供其他进程使用。该进程所唯一保留的是内核进程表中的一条记录,其中记录了子进程的ID、终止状态、资源使用数据等信息。当父进程执行wait()后,由于不再需要子进程所剩余的最后信息,故而内核将删除僵尸进程,如果父进程未执行wait()即退出,那么init进程将接管子进程并自动调用wait(),从而从系统中移除僵尸进程。
父进程应该使用wait()来防止僵尸进程的积累。
如果父进程创建了某一子进程,但并未执行wait(),那么在内核的进程表中将为该子进程永久保留一条记录,如果存在大量此类僵尸进程,它们势必填满内核进程表,从而阻碍新进程的创建。因为僵尸进程无法通过信号杀死,从系统中移除它们的唯一方法就是杀掉它们的父进程,由init进程接管和等待这些僵尸进程,从而清除它们。
无论一个子进程何时终止,系统都会向其父进程发送SIGCHLD信号,对该信号的默认处理是将其忽略,不过也可以安装信号处理程序来捕获并调用wait()来处理僵尸进程。
调用execve()之后,因为同一进程仍然存在,所以进程ID保持不变。由于是将调用程序取而代之,对execve()的成功调用将永不返回,而且也无需检查execve()的返回值,因为该值肯定为-1.实际上一旦函数返回,就表明发生了错误。
UNIX内核运行解释器脚本的方式与二进制程序无异,前提是脚本必须满足下面两点要求:
由exec()的调用程序所打开的所有文件描述符在exec()的执行过程中会保持打开状态,且在新程序中依然有效。shell可以利用这一特性为其所执行的程序处理I/O重定向。
打开进程记账功能后,内核会在每个进程终止时将一条记账信息写入系统级的进程记账文件,这条账单记录包含了内核为该进程所维护的多种信息,包括终止状态以及进程消耗的CPU时间。自内核2.6.10开始,只有当最后一个线程退出时才会为整个进程保存一条账单记录。如果进程的信息并未由其父进程进行监控和报告,那么就可以使用进程记账来获取。
特权进程可以使用系统调用acct()来打开和关闭进程记账功能,应用程序很少使用这一系统调用。
如果系统崩溃,则不会为当前运行的进程记录任何记账信息。
如果开启进程记账特性,且磁盘空闲空间低于low-water百分比,将暂停记账,如果磁盘空间升至low-water之上,则恢复记账。
Linux特有的clone()系统调用也能创建一个新进程,相比于fork(),clone()在进程创建期间对步骤的控制更为精确(通过各种位掩码的组合来指定),clone()主要用于线程库的实现。与fork()不同的是,clone()生成的子进程继续运行时不以调用处为起点,而是调用以参数func所指定的函数。
clone产生的子进程对调用进程的内存既可以获取,也可以共享。但是不能使用父进程的栈,调用者必须为子进程分配一块大小适中的内存空间供子进程的栈使用。
对术语“线程”和“进程”的区分不过是在玩弄文字游戏,实际上线程和进程都是内核调度实体(KSE,kernel scheduling entity),只是与其它KSE之间对属性的共享程度不同(虚拟内存、打开的文件描述符、对信号的处置、进程的ID等)。实际上针对线程间属性共享的方案不少,POSIX线程规范只是其中之一。
POSIX标准规定进程的所有线程共享同一进程ID(即每个线程调用getpid()都应返回相同值),Linux从2.4开始引入了线程组,线程组就是共享同一线程组标识(TGID)的一组KSE,getpid()所返回的就是调用者的TGID,即TGID和进程ID是一回事。
一个线程组内的每个线程都拥有一个唯一的线程标识符用以标识自身,线程可以通过gettid()来获取自己的线程ID。线程ID与进程ID使用相同的数据类型pid_t来表示,线程ID在整个系统中是唯一的,且除了线程担当进程中线程组首线程的情况之外,内核能够保证系统中不会出现线程ID和进程ID相同的情况。
线程组中首个线程的线程ID与线程组ID相同,也将该线程称为线程组首线程。线程组中的所有线程拥有同一个父进程ID,即与线程组首线程ID相同。
如果一个线程组中的任一线程调用了exec(),那么除了首线程之外的其他线程都会终止,新进程将在首线程中执行。
如果线程组中的某个线程调用fork()或vfork()创建了子进程,那么组中的任何线程都可以使用wait()或类似函数来监控该子进程。
容器是轻量级虚拟化的一种形式,将运行于同一内核的进程组从环境上彼此隔离,如同运行在不同的机器上一样。容器可以嵌套,一个容器可以包含另一个容器。为实现容器,内核开发者不得不为内核中的各种全局系统资源提供一个间接层,以便每个容器能够为这些资源提供各自的实例。这些资源包括:进程ID、网络协议栈、uname()返回的ID、用户和组ID命名空间等等。
大体上来说,fork()相当于仅设置了flags为SIGCHID的clone()调用,而vfork()相当于设置了如下flags的clone():
CLONE_VM | CLONE_VFORK | SIGCHLD
Pthreads,即POSIX线程。
同一个进程中的多个线程可以并发执行,在多处理器环境下,多个线程可以同时并行。
同时执行4个线程的进程
线程主要是为了解决进程存在的两个问题:
启动程序时产生的进程只有单条线程,称之为初始线程或者主线程。主线程执行了return语句(在main函数中),将会导致进程中的所有线程立即终止。
函数pthread_join()等待由thread标识的线程终止,如果线程已经终止,pthread_join()会立即返回。若线程未分离,则必须使用pthread_join()来进行连接,如果未能连接那么线程终止时将产生僵尸线程(与僵尸进程的概念类似)。pthread_join()执行的功能类似于针对进程的waitpid()调用,不过二者之间存在一些显著差别:
默认情况下线程是可连接的,即当线程退出时其他线程可以通过调用pthread_join()获取其返回状态。有时候可能不关心线程的返回状态,只是希望系统在线程终止时能够自动清理并移除之,在这种情况下可以调用pthread_detach()并向thread参数传入指定线程的标识符,将该线程标记为处于分离状态。一旦线程处于分离状态,就不能再使用pthread_join()来获取其状态,也无法使其重返可连接状态。
线程相对于进程的优点:
线程相对于进程的缺点:
各线程所独有的属性:
互斥量可以帮助线程同步对共享资源的使用,以防止如下情况的发生:线程甲试图访问一共享变量时,线程乙正在对其进行修改。 条件变量是对互斥量的补充,允许线程相互通知共享变量的状态发生了变化。
临界区是指访问某一共享资源的代码片段,并且这段代码的执行应为原子操作,即同时访问同一共享资源的其他线程不应中断该片段的执行。
当超过一个线程加锁同一组互斥量时,就可能发生死锁,要避免此类死锁问题最简单的方法是定义互斥量的层级关系,当多个线程对一组互斥量操作时总是应该以相同的顺序对改组互斥量进行锁定。
条件变量允许一个线程就某个共享变量或其他资源的状态变化通知其他线程,并让其他线程等待(阻塞于)这一通知。即允许一个线程休眠,直至接获另一线程的通知去执行某些操作。条件变量总是和互斥量结合使用,条件变量就共享变量的状态改变发出通知,而互斥量则提供对该共享变量访问的互斥。
条件变量的主要操作是发送信号和等待。发送信号操作即通知一个或多个处于等待状态的线程某个共享变量的状态已经改变。等待操作是指在收到一个通知前一直处于阻塞状态。
若函数可同时供多个线程安全调用,则称之为线程安全函数。导致线程不安全的典型原因是:使用了在所有线程之间共享全局或静态变量。实现函数线程安全最为有效的方式是使其可重入(应以这种方式来实现所有新的函数库)。
线程特有数据、线程局部数据,略。
若将线程的取消性状态和类型分别置为启用和延迟,仅当线程抵达某个取消点时取消请求才会起作用(目标线程如何响应取决于其取消性状态和类型)。
创建线程时每个线程都有一个属于自己的线程栈,且大小固定。32位系统上缺省大小为2MB。
只要有任一线程调用了exec()函数,调用程序将被完全替换,除了调用exec()的线程之外,其他所有线程都将立即消失,没有任何线程会针对线程特有数据执行解构函数,也不会调用清理函数。该进程的所有互斥量和属于进程的条件变量都会消失。
当多线程进程调用fork()时,仅会将发起调用的线程复制到子进程中(子进程中该线程的线程ID和父进程中发起fork()调用线程的线程ID一致),其他线程均在子进程中消失,也不会为这些线程调用清理函数以及针对线程特有数据的解构函数。
线程API的不同实现模型等,详略。
不要将线程与信号混合使用,只要可能应用多线程的程序都应该避免使用信号。
进程组是一组相关进程的集合,会话是一组相关进程组的集合。 进程组由一个或多个共享同一进程组标识符的进程组成。每个进程组拥有一个首进程,即创建该组的进程,其进程ID为该进程组的ID,新进程会继承其父进程所属的进程组ID。一个进程可能会因为终止而退出进程组,也可能会因为加入了另外一个进程组而退出进程组(进程组首进程无需是最后一个离开进程组的成员)。
会话首进程是创建该新会话的进程,其进程ID会成为会话ID,新进程会继承其父进程的会话ID。一个会话中的所有进程共享单个控制终端,控制终端会在会话首进程首次打开一个终端设备时被建立。一个终端最多可能会成为一个会话的控制终端。在任一时刻会话中的其中一个进程组会成为终端的前台进程组,其他进程组会成为后台进程组。只有前台进程组中的进程才能从控制终端中读取输入。当用户在控制终端中输入一个信号生成终端字符后,该信号会被发送到前台进程组中的所有成员。
会话和进程组的主要用途是用于shell作业控制。如对于交互式登录来讲,控制终端是用户登录的途径。登录shell是会话首进程和终端的控制进程,也是其自身进程组的唯一成员。从shell中发出的每个命令或通过管道连接的一组命令都会导致一个或多个进程的创建,并且shell会把所有这些进程都放在一个新进程组中。当命令或以管道连接的一组命令以&符号结束时,会在后台进程组中运行这些命令,否则就会在前台进程组中运行这些命令。在登录会话中创建的所有进程都会成为该会话的一部分。
进程组具备两个有用的属性:
当一个控制进程失去其终端连接之后,内核会向其发送一个SIGHUP信号来通知它这一事实,这种情况可能会在两种场景中出现:
nohup命令可以用来使一个命令对SIGHUP信号免疫,即将SIGHUP信号的处理设置为SIG_IGN。bash内置的siown命令提供类似的功能。
作业控制允许一个shell用户同时执行多个命令,其中一个命令在前台运行,其他命令在后台运行。作业可以被停止和恢复以及在前后台之间移动。
当输入的命令以&符号结束时,该命令会作为后台任务运行。可以使用jobs命令列出所有后台作业。使用fg命令来将后台作业移动到前台。
作业控制状态
Linux与大多数其他UNIX实现一样,调度进程使用CPU的默认模型是循环时间共享。每个进程轮流使用CPU直至时间片被用光或自己自动放弃CPU(如sleep或者被I/O阻塞)。
进程特性nice值(-20~19)允许进程间接地影响内核的调度算法。非特权进程只能降低自己的优先级,即赋予一个大于默认值0的nice值,这样做之后就对其他进程“友好(nice)”了。给一个进程赋予一个低优先级(即高nice值)并不会导致它完全无法用到CPU,但会导致它使用CPU的时间变少。
为了满足实时进程调度策略,内核必须要提供工具让高优先级进程能快速地取得CPU的控制权,抢占当前运行的所有进程。Linux提供了99个实时优先级。
在循环(SCHED_RR)调度策略中,优先级相同的进程以循环时间分享的方式执行,进程每次使用CPU的时间为一个固定长度的时间片。一旦被调度执行之后,使用SCHED_RR策略的进程会保持对CPU的控制直到下列条件之一得到满足:
在循环(SCHED_RR)调度策略中当前运行的进程可能会因为如下原因被抢占:
当一个进程在一个多处理器系统上被重新调度时不一定还在上一次执行的CPU上执行,考虑到CPU高速缓冲器数据的切换,这将对性能有一定的影响。Linux2.6内核尝试了给进程保证软CPU亲和力,即在条件允许的情况下进程重新被调度时将被调度到原来的CPU上运行。
getrusage()允许一个进程监控自己及其子进程已经用掉的资源。getrlimit()/setrlimit()则可以用来修改和获取调用进程对各类资源的消耗限值。
使用shell的内置命令ulimit可以设置shell的资源限制。
daemon是一种具备下列特征的进程:
常见的daemon进程:cron、sshd、httpd、inetd;(很多daemon程序都以d结尾)
要变成daemon,一个程序需要完成下面的步骤:
becomeDaemon()函数用来完成上述步骤以将调用者变成一个daemon。该函数接收一个掩码参数用来有选择地执行其中的步骤。
syslog工具提供了一个集中式日志工具,系统中的所有应用程序都可以使用这个工具来记录日志消息。 系统日志概览
特权程序能够访问普通用户无法访问的特性和资源,一个程序可以通过下面两种方式以特权方式运行:
Linux能力模型将传统的all-or-nothing UNIX权限模型划分为一个个被称为能力的单元,一个进程能够独立地启用或禁用单个的能力。通过只启用进程所需的能力使得程序能够在不拥有完整的root权限的情况下运行。
传统的UNIX权限模型将进程分为两类:能通过所有权限检测的有效用户ID为0的进程和其他所有需要根据用户和组ID进行权限检测的进程。这个模型的粗粒度划分是一个问题,Linux能力模型优化了这个问题。 在大多数时候Linux能力模型对程序员来讲都是不可见的,原因是当一个对能力一无所知的应用程序的有效用户ID为0时,内核会赋予该进程所有能力。
每个进程都拥有3个相关的能力集:
实际上能力是一个线程级的特性,进程中的每个线程的能力都可以单独进行调整,在/proc/PID/task/TID/status文件中可以查看一个多线程进程中某个具体线程的能力,/proc/PID/status文件显示了主线程的能力。
登录记账关注的是哪些用户当前登录进了系统,以及记录过去的登录和登出行为:
共享库是一种将库函数打包成一个单元使之能够在运行时被多个进程共享的计数。
构建程序的最简单方式是将每一个源文件编译为目标文件,然后将这些目标文件链接在一起组成一个可执行程序:
$ cc -g -c prog.c mod1.c mod2.c mod3.c $ cc -g -o prog_nolib prog.o mod1.o mod2.o mod3.o
链接由链接器程序ld来完成,当使用gcc时,编译器会在幕后调用ld。
静态库也称为归档文件,是一个保存所有被添加到其中的目标文件的副本的文件,在链接命令行中只需要指定静态库的名称,而无需一个个地列出目标文件,链接器知道如何搜索静态库并将可执行程序需要的对象抽取出来。
共享库的关键思想是目标模块的单个副本由所有需要这些模块的程序共享,目标模块不会被复制到链接过的可执行文件中,相反当第一个需要共享库中的模块的程序启动时,库的单个副本就会在运行时被加载进内存,后面使用同一个共享库的其他程序启动时,会使用已经被加载进内存的库的副本。所以使用共享库意味着可执行程序需要的磁盘空间和虚拟内存更少了。
虽然共享库的代码是由多个进程共享的,但是其中的变量却不是,每个使用库的进程会拥有自己的在库中定义的全局和静态变量的副本。
创建共享库:
$ gcc -g -c -fPIC -Wall mod1.c mod2.c mod3.c $ gcc -g -shared -o libfoo.o mod1.o mod2.o mod3.o
创建一个共享库并将一个程序与该共享库链接起来
加载共享库的程序的执行
可以通过查看/proc/PID/maps文件来列出进程用到的所有共享库。
ldd命令用于显示一个程序运行所需的所有共享库:
ldd ls
一般共享库及其关联的符号链接会被安装在标准目录中以方便编译器搜索,标准目录包括:
在默认情况下当链接器能够选择名称一样的共享库和静态库时,会优先使用共享库。
dlopen API使得程序能够在运行时打开一个共享库,根据名字在库中搜索一个函数,然后调用这个函数(即延迟加载),在运行时采用这种方式加载的共享库通常称为动态加载的库。
有些时候需要监控动态链接器的操作以弄清它在搜索哪些库,可以通过LD_DEBUG环境变量来完成:通过将这个变量设置为一个或多个关键词可以从动态链接器中得到各种跟踪信息。
$ LD_DEBUG=libs date
UNIX IPC工具分类
使用管道在两个进程间交换数据
可以将通信工具分为两类:
大多数现代UNIX系统都提供了三种形式的共享内存:
对于共享内存的IPC方式来说,可以通过各种同步方法,比如信号量、文件锁、互斥体和条件变量等,来防止进程执行诸如同时更新一块共享内存或同时更新文件的同一个数据块之类的操作。
在诸多IPC方法中,只有socket允许进程通过网络来进行通信,socket可以用于两个域中:UNIX和Internet。通常将一个使用UNIX socket进行通信的程序转换为一个使用Internet socket进行通信的程序只需要做出微小的改动。
管道是UNIX上最古老的IPC方法:
$ ls | wc -l
实际上这两个进程并不知道管道的存在,它们只是从标准文件描述符中读取数据和写入数据,shell必须要完成相关的工作。试图从一个当前为空的管道中读取数据将会被阻塞直到至少有一个字节被写入到管道中为止。在管道中数据的传递方向是单向的。
管道其实是在内核内存中维护的缓冲器,这个缓冲器的存储能力是有限的,一旦管道被填满之后,后续向该管道的写入操作就会被阻塞直到读者从管道中移除了一些数据为止。
从语义上来讲,FIFO与管道类似,它们之间最大的差别在于FIFO在文件系统中拥有一个名称,并且其打开方式与打开一个普通文件是一样的。这样就能将FIFO用于非相关进程之间的通信。
可以使用mkfifo命令在shell中创建一个FIFO。一旦FIFO被创建,任何进程都可以打开它,只要它能够通过常规的文件权限检测。
分隔字节流中消息的常用方法
消息队列、信号量、共享内存。
略。
在客户端-服务器 IPC中使用单个消息队列
一个客户端使用一个消息队列的客户端-服务器 IPC
信号量不是用来在进程间传输数据的,而是用来同步进程的动作的。一个信号量是一个由内核维护的整数,其值被限制为大于或等于0,在一个信号量上可以执行各种操作:
使用信号量同步两个进程
使用信号量确保对共享内存的互斥、交替访问
mmap()系统调用在调用进程的虚拟地址空间中创建一个新的内存映射,映射有两种:
当两个或者更多个进程共享相同的分页时,每个进程都有可能会看到其他进程对分页内容做出的变更,这取决于映射是私有的还是共享的:
内存映射I/O所带来的性能优势在大型文件中执行重复随机访问时最有可能体现出来。如果顺序地访问一个文件,并假设执行I/O时使用的缓冲区大小足够大以至于能够避免执行大量的I/O系统调用,那么与read()和write()相比,mmap()带来的性能上的提升就非常有限或者说根本就没有带来性能上的提升。性能提升的幅度之所以非常有限的原因是不管使用何种技术,整个文件的内容在磁盘和内存之间只传输一次,效率的提高主要得益于减少了用户空间和内核空间之间的一次数据传输,并且与磁盘I/O所需的时间相比,内存使用量的降低通常是可以忽略的。
内存本身也有一些开销:映射、分页故障、解除映射、更新硬件内存管理单元的超前转换缓冲区。
内存映射的常见用途:
在一些应用程序中将一个进程的虚拟内存的部分或者全部锁进内存以确保它们总是位于物理内存中是非常有用的。对被锁住的分页的访问可以确保永远不会因为分页故障而发生延迟,这对于那些需要确保快速响应时间的应用程序来讲是很有用的。此外也可以防止内存被恶意溢出导致交换行为,从而产生安全问题。
消息队列、信号量、共享内存。 略。
略。
通过检查Linux特有的/proc/locks文件可以看到当前系统中存在的锁。
一些程序,特别是很多daemon,需要确保同一时刻只有一个程序实例在系统上运行,常见的实现方式是让daemon在一个标准目录中创建一个文件,并在该文件上放置一把写锁。/var/run目录通常是存放此类锁文件的位置,通常daemon会把进程ID写入锁文件,因此这个文件在命名时常以.pid为扩展名。
socket存在于一个通信domain中,它确定了识别出一个socket的方法,以及socket通信的范围。
现代操作系统至少支持下列domain:
有关socket的几个关键系统调用:
理解accept()的关键点是它会创建一个新socket(),并且正是这个新socket()会与执行connect()的对等socket进行连接。
socket I/O可以使用传统的read()和write()系统调用或使用一组socket()特有的系统调用如send()、recv()、sendto()以及recvfrom()来完成。默认情况下,这些系统调用在I/O操作无法被立即完成时会阻塞。
流socket上用到的系统调用
数据报socket上用到的系统调用
在UNIX domain中,socket地址以路径名来表示,但在这些socket上发生的I/O无须对底层设备进行操作。
对于UNIX domain socket来讲,数据报的传输是在内核中发生的,并且也是可靠的。所有消息都会按序被递送并且也不会发生重复的情况。
socket文件的所有权和权限决定了哪些进程能够与这个socket进行通信。
TCP/IP套件中的协议
SOCKET_RAW允许应用程序直接与IP层进行通信。
UNIX domain的数据报socket是可靠的,但UDP socket则是不可靠的:数据报可能会丢失、重复或到达的顺序与被发送的顺序不一致。 在一个UNIX domain数据报socket上发送数据会在接收socket的数据队列满时阻塞,与之不同的是,使用UDP时如果进入的数据报会使接受者的队列溢出,那么数据报就会没静默丢弃。
系统上运行的服务和端口号信息会记录在/etc/services文件中。
之所以要选择使用UNIX domain socket是存在几个原因的:
使用socket的网络服务器端程序有两种常见的设计方式:
对于负载很高的的服务器来说,为每个客户端创建一个新的子进程(甚至线程)所带来的开销对服务器来说是个沉重的负担,一般会考虑如下设计方案:
如果出现了部分I/O现象,即read()返回的字节数少于请求的数量,或者阻塞式的write()调用在完成了部分数据的传输后被信号处理例程中断,那么有时候需要重新调用系统调用来完成全部数据的传输,这可以通过两个系统调用readn()和writen()来实现。
使用close()系统调用会将双向通信信道上的两端都关闭,而使用shutdown()系统调用可以只关闭连接的一端,这样数据只能在一个方向上通过socket传输。
sendfile()系统调用可以绕过用户控件缓冲区,直接发送文件。
TCP报文格式
TCP结点以状态机的方式来建模,状态迁移图如下:
终端设备的输入和输出队列
同时监控多个I/O文件描述符的I/O模型:
一些其他的UNIX实现了类似epoll的机制,比如Solaris提供了特殊的/dev/poll文件,其他一些变种的BSD提供了kqueue API。
Libevent库是这样一个软件层,它提供了检查文件描述符I/O事件的抽象,其底层机制能够以透明的方式应用上述的各种I/O多路复用技术。
详细的比较,略。
伪终端是一对互联的虚拟设备:主伪终端和从伪终端。伪终端对提供了一条双向IPC通道,从伪终端表现得就像一个标准终端一样,所有可以施加于终端设备的操作同样可以施加于伪终端从设备上。
两个程序通过伪终端通信
ssh使用伪终端的原理
一对伪终端同一个双向管道相似,任何写入到伪终端主设备的数据都会在从设备端作为输入出现,而任何写入到从设备的数据也会在主设备端作为输入出现。