linux系统编程
原作名:Linux System Programming
作者: Robert Love
第一章 入门和基本概念
1.1 系统编程
应用程序需要与更高层次的库进行交互,系统程序就是与内核和系统库打交道,那些库把硬件和操作系统的细节抽象封装起来。
1.1.1 为什么要学习系统编程
1.1.2 系统编程的基础
Linux系统编程有三大基石:
系统调用、C库和C编译器
1.1.3 系统调用
系统调用是为了从操作系统请求一些服务和资源,是从用户空间发起的函数调用。Windows其系统调用号称有几千个,Linux x86-64体系结构系统系统调用大概只有300个。
一方面是从安全性和可靠性角度考虑,需要禁止用户空间的应用程序直接执行内核代码或操纵内核数据。另一方面是内核也必须提供一种机制,使用户空间可以通过该机制执行系统调用。
1.1.4 C库
现代Linux系统中,C库由GNU libc提供,简称glibc。
1.1.5 C编译器
gcc:GUN编译器工具集(GNU Compiler Collection)
标准C编译器是由gcc提供的。时至今日,gcc已经成了GNU编译器家族的代名词。
1.2 API和ABI
影响可移植性的定义和描述
API:应用程序编程接口
ABI:应用程序二进制接口
都是用来定义和描述计算机软件的不同模块间的接口的。
1.2.1 API
API定义软件模块之间交互的接口
1.2.2 ABI
ABI定义软件模块在特定体系结构上的二进制接口,ABI试图确保的是目标代码能在任何系统上正常工作。
ABI关注调用约定、字节序、寄存器使用这些问题。尽管ABI试图做到兼容不同的计算机体系结构,但是收效甚微。实际每个计算机体系结构都定义了自己的ABI,ABI是操作系统和体系结构共同提供的功能。
ABI是由内核和工具链定义和实现的。
1.3 标准
Linux最终也没有遵循一个统一的标准
1.3.1 POSIX和SUS的历史
ieee定义了POSIX标准,The Open Group发布SUS,SUS合并了POSIX最新的标准。
1.3.2 C语言标准
C语言标准最新版本是ISO C11。
C++11标准与之前的C++标准有很大的不同,有人建议C++11作为一门新的语言。
1.3.3 Linux和标准
LSB是几大linux厂商在Linux基金会推动下的联合项目,LSB扩展了POSIX和SUS,添加了一些自己的标准,Linux厂商都在一定程度商遵循LSB。
1.3.4 本书和标准
linux内核3.9,gcc编译器4.8,C库2.17。
1.4 Linux编程的概念
1.4.1 文件和文件系统
一切皆文件,文件有文件描述符,是一个整数,用户空间可见,通过文件描述符操作文件。
(1)普通文件:
文件的地址称为文件位置或文件偏移。
同一个文件可以被不同的进程同时打开,但是结果不可预测。
文件虽然通过文件名访问,但是文件本身没有跟文件名相关联,与文件关联的是索引节点,是文件系统为文件分配的唯一整数值。索引节点中包含文件相关的元数据,修改时间戳,类型长度位置所有者等,但不包含文件名。
(2)目录和链接
用户空间可见的是文件名,文件名与索引节点之间的配对称为链接,目录也是作为普通的文件存储,特殊的是,目录有关联的索引节点,并且内核不支持打开和操作目录文件。
(3)硬链接
Linux支持多个名字(多个路径)链接到一个索引节点。
(4)符号链接
硬链接不能跨越多个文件系统,符号链接可以,符号链接本身也是一个文件,该文件包含自己的信息又包含所链接文件的绝对位置,符号链接不是透明的,并且带来额外开销。符号链接通常作为文件访问的快捷方式。
(5)特殊文件
使某些抽象可以适用于文件系统。贯彻一切皆文件理念。
快设备文件和字符设备文件:访问设备划分为字符设备和快设备,字符设备作为线性字节队列来访问,如键盘。块设备作为字节数组来访问,用户空间可以任意随意访问数组中任何字节,如硬盘、软盘。
命名管道:FIFO,以文件描述符作为通信信道的进程间通信机制。
套接字:socket在互联网上使用主机名和端口号来描述,UNIX域套接字使用文件系统上特殊文件进行交互。
(6)文件系统和命名空间
Linux提供全局统一的文件和目录命名空间。再UNIX中,命名空间是统一的。在文件和目录的全局命名空间中,删除或添加文件,称为挂载和卸载。
块设备最小寻址单元为扇区,是设备的物理属性。块设备无法访问比扇区更小的存储单元。
文件系统的最小逻辑寻址单元是块。块大小一般是2的指数倍乘以扇区大小,块通常比扇区大,但必须小于页。
页是内存的最小寻址单元。
1.4.2 进程
Linux中,进程的重要性仅次于文件。
ELF(Executable and Linkable Format)(可执行和可链接的格式),可执行性格式包含元数据、多个代码段和数据段。
最重要的段是文本段、数据段和bss段。
进程是一种虚拟抽象,内核支持抢占式和虚拟内存。通过虚拟内存和分页,内核支持多个进程共享系统,好像是自己独占的一样。
(1)线程
UNIX系统的简洁性,UNIX的进程大多是单线程的。
Linux内核实现了独特的线程模型:他们其实是共享某些资源的普通进程。
(2)进程层次结构
进程树。进程树的根是第一个进程,init进程,新的进程通过系统调用fork()创建。fork()会创建原进程的副本,原进程为父进程,fork()创建的为子进程。
父进程先终止,init会被指定为其新的父进程。
僵尸进程,一个进程已经终止,父进程不知都其状态,就变成僵尸进程。
1.4.3 用户和组
每个用户和一个唯一的正整数关联,该正整数称为用户ID,root用户的uid是0。
每个进程和一个uid关联,用来识别运行这个进程的用户,称为进程的真实uid。
除了真实uid,进程还拥有有效uid(会变,从而支持进程切换为其他用户权限来执行),保留uid(保留原来的有效uid)和文件系统uid。
每个用户属于一个或多个组,进程也和一系列组ID(gid)关联,同样有真实gid、保留gid、有效gid、文件系统gid。
1.4.4 权限
三类权限,所属用户的权限、所属组的用户的权限、其他用户的权限。
每一类权限都有三位,分别表示读、写、执行的权限。
1.4.5 信号
信号用于通知进程发生了某些事件,是一种单向异步通知机制。
1.4.6 进程间通信
Linux支持的进程间通信机制包括管道、命名管道、信号量、消息队列、共享内存和快速用户空间互斥。
1.4.7 头文件
除了glibc提供有头文件,内核本身也提供头文件。
1.4.8 错误处理
在单线程程序中,errno是个全局变量,在多线程程序中,每隔线程都有自己的errno,它是线程安全的。
第二章 文件I/O
每个进程都至少包含3个文件描述符:0,1和2。0表示标准输入,1表示标准输出,2表示标准错误。用户可以重定向这些文件描述符。
2.1 打开文件
2.1.1 系统调用open()
int open(const char *name, int flags, mode_t mode)
系统调用open成功,会返回文件描述符。指向路径名name所指定的文件,文件位置即文件的起始位置(0).
(1)flags参数:
O_RDONLY、O_WRONLY、O_RDWR分别表示只读、只写或读写模式打开文件。
flags参数还可以和其他一些值进行按位或运算,修改打开文件的行为。
2.1.2 新建文件的所有者
文件所有者的uid即创建该文件的进程的有效uid,新建文件所属的用户组就相对复杂一些,对于那些对所属组非常关心的代码,就需要通过系统调用fchown()手动设置所属组。
2.1.3 新建文件的权限
调用open()时,参数mode可不指定,但是当flags给定O_CREATE参数时,mode必须指定,否则不仅行为未定义,还会导致糟糕的结果。
参数mode是常见的UNIX权限位集合,比如0644和0700,在POSIX中定义了几种宏通过按位或方式指定权限。
实际上最终写入磁盘的权限位是由mode参数和用户的文件创建掩码(umask)执行按位与操作而得到的。对于程序员通常不需要考虑umask。
2.1.4 create函数
open()的flags参数有一种通常的组合模式:O_WRONLY|O_CREATE|O_TRUNC,因此有一个函数专门提供这个功能:
int creat(const char* name,mode_t mode);
2.1.5 返回值和错误码
open()和creat() 调用成功时会返回文件描述符,而调用失败时返回-1,并把errno设置成相应的错误码。
2.2 通过read()读文件
ssize_t read(int fd,void *buf,size_t len);
从fd指向的文件的当前偏移量开始读取len字节到buf指向的内存中。调用成功时返回写入buf的字节数,失败返回-1.
2.2.1 返回值
read()函数可用字节数小于len,调用会被打断,如果是管道,管道可能被破坏;返回0时,表示读取到文件结尾,阻塞模式下,如果文件没有一个字节可读,调用会发生阻塞,直到有数据可读。
2.2.2 读入所有字节
ssize_t ret;
while (len!=0 && (ret=read(fd,buf,len))!=0)
{
if(ret==-1)
{
if(error==EINTR)
continue;
perror(“read”);
break;
}
len-=ret;
buf+=ret;
}
读取数据时最好不要采用部分读入的方式
2.2.3 非阻塞读
没有数据可读时立即返回,错误errno==EAGAIN
2.2.4 其他错误码
2.2.5 read()调用的大小限制
类型时size_t保存字节大小,类型ssize_t是有符号的ssize_t,在32位系统上对应unsigned int 和int
2.3 调用write()写
ssize_t write(int fd, const void* buf, size_t count);
函数返回写入的大小;
write也应该检查部分写的场景
2.3.1 部分写(partial write)
对于普通文件不需要执行循环写操作,对于例如socket则需要循环写来保证写了所有请求的字节。
2.3.2 append(追加)模式
当有多个进程都在执行写操作时,append模式可以保证总是从文件末尾写。
2.3.3 非阻塞写
看不懂说了个啥
2.3.4 其他错误码
2.3.5 write()大小限制
count大于SSIZE_MAX,调用write结果是未定义的。
2.3.6 write()行为
当调用write()时,内核已经把数据从提供的缓存中拷贝到内存的缓存中去了,但是并没有真正写入磁盘,因为处理器和硬盘之间的性能差异很难实现。
把缓冲区的数据写入磁盘的过程叫做回写,在后台,内核会把所有还未回写的缓存区,进行排序优化,再写入磁盘。通过这种方式,write()可以快速返回,写磁盘可以留到系统空闲时期。
并且这种方式不会影响read(),调用read()时,会从缓存区读数据,而不是从旧的磁盘上读数据。
延迟写的问题在于无法强制顺序写,前面说到内核会把所有还未回写的缓存区,进行排序优化,内核出于性能考虑,并不是按时间循序来排序的。不过对于大多数的写操作并不关心写顺序,数据库是少数几个关心写操作顺序的。
对于写无法保证写顺序问题,内核通过设置最大缓存时效,并在操过给定时效前将数据写入。可通过/proc/sys/vm/dirty_expire_centisecs来配置。
2.4 同步I/O
如前面所说,通过写缓冲带来了性能的提升,因此,linux也提供一些选择,可以牺牲声能来换得同步。
2.4.1 fsync()和sdatasync()
int fsync(int fd);
调用fsync()可以保证回写完数据和元数据后函数再返回。但是实际上对于包含写缓存得硬盘,fsync是无法知道数据是否已经真正写到物理硬盘了得,不过,硬盘驱动器缓存中的数据会很快写道硬盘。
int fdatasync(int fd);
fdatasync()跟fsync不同处在于fdatasync不会写入全部元数据,只写入数据和将来访问文件所需要的元数据(例如文件的大小)。
返回值和错误码
2.4.2 sync()
void sync(void);
对磁盘的所有缓区进行同步,函数不会等待同步后返回,只是会立即把同步的消息传达给内核。POSIX标准一般建议多次调用sync(),但是对于Linux而言,synx()一定是等到所有缓冲区都写入才会返回。调用一次就行。
2.4.3 O_SYNC标志位
系统调用open()可以使用O_SYNC,读总是同步的。O_SYNC对于write就是每次调用write()后,隐式调用fsync()后才返回。但是这种方式开销很大,调用fsync()和fdatasync()由应用决定是否需要确保同步写入,开销较低
2.4.4 O_DSYNC和O_RSYNC
POSIX定的另两个open()标志位,在Linux上,跟O_SYNC完全相同。
O_DSYNC每次写操作只同步普通数据,不同步元数据,可以理解为执行write后调用fdatasync。
O_RSYNC,该标志位必须和O_DSYNC或O_RSYNC一起使用,读操作总是同步的,但是读操作会导致文件的元数据被修改,比如文件访问时间,因此O_RSYNC保证读操作引起的元数据修改写入磁盘后read()函数再返回。
2.5 直接I/O
如前面所述,Linux内核实现了复杂的管理机制,高性能的应用希望越过这个复杂的层次结构,进行独立的I/O管理,在open()中指定O_DERECT标志位能使得内核干涉最小,内核会忽略页缓存机制,所有I/O都是同步的。
但是使用直接I/O时,请求长度、缓冲区对齐以及文件偏移量都必须是底层设备扇区大小(通常是512字节)的整数倍。
2.6 关闭文件
int close(int fd);
成功时返回0,出错时返回-1。
文件关闭并非意味着该文件的数据已经写到磁盘。
如果文件已经从磁盘上解除链接,但是解除前还一直打开着,直到文件关闭,文件才会真正从磁盘上删除。
错误码
一个常见的错误是没有检查close()的返回值。
2.7 用Iseek()查找
一般请款,I/O操作是线性的,但是有的应用程序需要跳跃式操作。Iseek()系统调用能够将文件描述符的位置指针设置成指定值。
off_t lseek(int fd, off_t pos, int origin);
origin参数:
SEEK_CUR:将文件位置设置成当前值再加上pos个偏移量。
SEEK_END:将文件位置设置成文件长度再加pos个偏移量
SEEK_SET:将文件位置设置成pos值。
2.7.1 在文件末尾后查找
ret = lseek(fd, (off_t)1688, SEEK_END);
查找到文件末尾没什么意义,但是如果该位置有个写请求,在文件的旧长度和新长度之间会用0来填充。
这种填充区间称为“空洞(hole)”,在UNIX系文件系统上,空洞不占物理内存空间,文件系统上所有文件的大小加起来可能会超过磁盘物理空间。包含空洞的文件称为“稀疏文件”。
2.7.2 错误码
2.7.3 限制
最大文件位置受限于off_t类型的大小,大部分计算机体系结构定义该值为C long类型,在Linux上是指字长,但是,内核在内部实现上是把便宜存储成C long long类型,对于32位计算机,会产生溢出EOVERFLOW错误。
2.8 定位读写
替代lseek()的读写
ssize_t pread(int fd, void *buf, size_t count, off_t pos);
从文件描述符fd的pos位置开始读
ssize_t pwrite(int fd, const void *buf, size_t count, off_t pos);
从文件描述符fd的pos位置开始写。
定位读写不会更新文件位置指针。
定位读写避免了使用lseek()时会出现的竞争,也就是可能一个进程写还没完成,另一个线程又调用lseek()更新了文件的位置。
2.9 文件截短
int ftruncate(int fd, off_t len);
int truncate(const char *path, off_t len);
前面也说过,可以把文件截短为比源文件更长,扩充的字节用0填充。
2.10 I/O多路复用
对于阻塞I/O,如果文件符一直没有数据,就会阻塞,此时线程也不能去操作其他文件描述符。在一个文件描述符依赖另一个文件描述符的情况下,更是有可能形成长时间的阻塞。
使用非阻塞I/O是一种解决办法,但是效率不高,进程需要连续随机发送I/O操作,等待某个打开的文件描述符可执行操作。
可以使进程睡眠,是的CPU可以执行其他任务,直到一个或多个文件描述符可以执行I/O时再唤醒进程。直到一个文件描述符可以执行时再唤醒进程。
Linux提供三种多路复用方案:select、poll、epoll。
2.10.1 select()
int select(int n,
fd_set *readfds,
fd_set *writefds,
fd_set *exceptfds,
struct timevla *timeout);
监视的文件描述符分为3类,分别等待不同的事件,对于readfds集中的文件描述符,监视是否有数据可读
对于writefds,监视某个写操作是否可以无阻塞完成
对于exceptfds,监视是否发生异常,或者出带外数据。
n为所有集合中的最大文件描述符+1
timeout为结构体:
struct timeval
{
long tv_sec;
long tv_user;
}
如果该参数不为NULL,在tv_sec秒tv_usec微妙后,即使没有一个文件描述符处于I/O就绪状态,select()调用会返回。
其实Linux会随着时间流逝自动修改timeout的值。
文件描述符集通过以下宏来操作
FD_CLR(int fd, fd_set *set);
从指定集中删除一个文件描述符
FD_ISSET(int fd, fd_set *set);
检查一个文件描述符是否在集中
FD_SET(int fd, fd_set *set);
向指定集中添加一个文件描述符
FD_ZERO(int fd, fd_set *set);
从指定文件描述符中删除所有文件描述符,每次调用select()之前,都应该调用一下。
返回值和错误码
。。。
select()示例
。。。
用select()实现可移植的sleep功能
select(i0.NULL.NULL.NULL,&tv);
pselect
int pselect(…, const struct timespec *timeout, const sigset_t *sigmask);
区别:
(1)理论上,timespect使用秒和纳秒,更精确;
(2)pselect调用不会修改timeout参数,在后续调用中,不需要初始化该参数;
(3)没有sigmask参数为NULL时,行为相同;sigmask为了解决文件描述符和信号之间等待而出现竞争条件。
2.10.2 poll()
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
struct pollfd{
int fd,
short events;
short revents;
}
每个pollfd指定一个被监视的文件描述符,可以给poll()传递多个pollfd结构体,是他能够监视多个文件描述符。
events是要监视的事件的位掩码
revents是结果事件的位掩码。
ppoll()
较于select,poll调用更优
2.11 内核内幕
Linux内核如何实现I/O,重点说明内核的三个主要子系统:
虚拟文件系统、页缓存和页回写
2.11.1 虚拟文件系统
VFS(virtual file switch)支持Linux内核在不了解文件系统类型时调用文件系统函数;
通过文件模型提供一种Linux内核文件系统必须遵循的框架。
当应用发起read()调用,从C库获取系统调用的定义,在编译时转化为相应的trap语句。一旦进程从用户空间进入内核,交给系统调用Handler处理,然后交给read()系统调用。
虚拟文件系统使得程序员不需要关心文件所在的文件系统和其存储介质。
2.11.2 页缓存
相当于当前的处理器速度,磁盘访问速度过慢,通过在内存中保存被请求的数据,内核后续对相同数据的访问直接从缓存的内存中访问,从而提高访问速度。
时间局部性:认为被访问的数据后续访问的可能性更大
页缓存大小是动态变化的,当消耗调所哟的动态内存时,新的内存请求出现,就会把页缓存拆剪掉,通过页缓存大小的动态变化,可以使得缓存尽可能多的数据;
把页缓存交换给磁盘要比清理经常使用的页缓存更有意义。
交换–缓存之间的平衡可以通过/proc/sys/vm/swappiness来调整,值越大优先保存页缓存。
空间局部性:每次读请求时,会从磁盘上读取多余的数据到内存。
2.11.3 页回写
前面说到write()系统调用是非同步的,数据被拷贝到缓冲区后,缓冲区会被标记为”脏“缓冲区,将磁盘跟缓冲同步,以下两个步骤可以触发:
(1)空闲内存小于设定的阈值
(2)脏缓冲区时长大于设定的阈值
第3章 缓冲I/O
块(block)是文件系统中最小存储单元的抽象。所有I/O操作都是在块大小或者块大小的整数倍上执行。
3.1 用户缓冲I/O
需要对普通文件执行很多轻量的I/O请求的程序通常会采用用户缓冲I/O。
用户读取一个字节的时候,实际上会读取一个块大小的字节,通过提供全为0的文件流。
块大小
块大小一般是512字节、1024字节、2048或者4069字节。把每次操作的数据设置为块大小的整数倍或约数,可以实现大规模的效率提升。
内核和硬件之间的交互单元是块,使用块大小或整数倍或约束可以保证I/O请求是块对齐的,避免内核内其他冗余操作。
用户可以在自己的程序中实现缓冲,但是大部分程序可以通过标准I/O库(C标准库)或iostream库来实现。
3.2 标准I/O
C标准库提供了标准I/O库(stdio)
标准I/O程序集并不直接操作文件描述符,而是通过唯一标识符,即文件指针来操作。
打开的文件称为流
3.3 打开文件
FILE fopen(const char path, const char *mode);
3.4 通过文件描述符打开流
FILE * fdopen(int fd, const char *mode);
3.5 关闭流
int fclose(FILE *stream);
在关闭前,所有被缓冲但还没有写出的数据都会被写出。
关闭所有流
int fcloseall(void);
会关闭和当前进程相关联的所有流,为Linux特有的。
3.6 从流中读数据
3.6.1 每次读取一个字节
int fgetc(FILE *stream);
读取一个字符并强转为unsigned char类型。stream必须以可读模式打开,记得检查错误返回。
把字符放回流中
int ungetc(int c, FILE *stream);
3.6.2 每次读一行
char * fgets(char *str, int size, FILE *stream);
从stream中读取size-1个字节的数据,并把结果保存到str中。
读取任意字符串
while(–n>0 && (c=fgetc(stream)) != EOF && (*s++ = c) !=d)
3.6.3 读二进制文件
size_t fread(void *buf, size_t size, size_t nr, FILE *stream)
3.7 向流中写数据
stream必须以可写模式打开。
3.7.1 写入单个字符
int fputc(int c, FILE *stream)
3.7.2 写入字符串
int fputs(const char *str, FILE * stream);
3.7.3 写入二进制数据
size_t fwrite(void buf, size_t size, size_t nr, FILE *stream);
3.8 缓冲I/O示例程序
3.9 定位流
标准I/O提供等价于系统调用lseek()的函数
int fseek(FILE *stream, long offset, int whence);
whence:SEEK_SET,stream指向的文件位置即offset,
SEEK_CUR:stream指向的文件位置即当前位置加上offset。
SEEK_END:文件末尾加offset
跟SEEK_SET等效的用法:
int fsetpos(FILE *stream, fpos_t *pos);
Linux上编程一般不适用这个接口,是为了给其他通过复杂数据类型表示流位置的平台。
void rewind(FILE *stream);
等价于:
fseek(stream, 0, SEEK_SET);
但是rewind会清空错误表示,需要判断调用是否为0
获取当前流的位置:
long ftell(FILE *stream);
获取当前流的位置并把当前流位置设置为pos
int fgetpos(FILE *stream, fpos_t *pos);
Linux上编程一般不适用这个接口,是为了给其他通过复杂数据类型表示流位置的平台。
3.10 Flush(刷新输出)流
int fflush(FILE *stream);
stream指向的流中所有未被写入的数据否会被flush到内核中。
这里的缓冲区是由C函数库维护的缓冲区,不是内核空间提到的缓冲区,只有需要访问磁盘或其他某些媒介时,才会发起系统调用。
fflush只是把数据写到内核缓冲区,而没有直接调用write。
3.11 错误和文件结束
int ferror(FILE *stream);
判断给定stream是否有错误标识
int feof(FILE *stream);
判断指定的stream流是否设置了文件结束标志
void clearerr(FILE *stream);
清空错误和文件结束标识,清空操作不可恢复。
3.12 获取关联的文件描述符
int fileno(FILE *stream);
最好永远不要混合使用文件描述符和基于流的I/O操作。
3.13 控制缓冲
标准I/O实现三种类型的缓冲
无缓冲:不执行用户缓存,数据直接提交给内核,标准错误默认采用无缓冲模式;
行缓冲:终端的默认缓冲模式
块缓冲:和文件相关的所有流都是块缓冲模式。
标准缓冲提供一个接口,可以修改使用的缓冲模式:
int setvbuf(FILE *stream, char *buf, int mode, size_t size);
在关闭流时,其使用的缓冲区必须存在,把缓存区定义为某个局部作用域时应该注意。
3.14 线程安全
多个线程访问共享数据时,有两个方法可以避免混乱:
(1)采取数据同步访问机制,也就是加锁
(2)把数据存储在线程的局部变量中,也称为线程封闭
标准I/O函数在本质上是线程安全的,也就是说标准I/O操作时原子操作。
3.14.1 手动文件加锁
void flockfile(FILE *stream);
等待指定stream被解锁,然后增加自己线程的锁计数
void funlockfile(FILE *stream);
减少和指定stream的锁计数。
3.14.2 对流操作解锁
Linux提供了一些函数,是不用加锁的标准I/O,可以带来性能提升,并且可以让代码简洁。
3.15 对标准I/O的批评
标准I/O最大的诟病是两次拷贝带来的性能开销。
第4章 高级文件I/O
4.1 分散/聚集 I/O
readv()和writev()
readv()从文件描述符fd中读取count个段到参数iov所指定的缓冲区中。
ssize_t readv(int fd, const struct iovec *iov,int count);
writev从参数iov指定的缓冲区中读取count个段数据,并写入fd中。
ssize_t writev(int fd, const struct iovec *iov, int count);
每个iovec结构体描述一个独立的、物理不连续的缓冲区,我们称其为段。
struct iovec{
void *iov_nase;
size_t iov_len;
}
一组段的集合称为向量。
实际上,Linux内核中所有的I/O都是向量I/O,read()和write()是作为向量I/O实现的,只不过向量中只有一个段。
4.2 Event Poll
由于poll()和select()的局限,Linux2.6内核引入了epool()机制。
4.2.1 创建新的epoll()实例
int epoll_create1(int flags);
调用成功时会创建新的epoll实例,并返回和该实例关联的文件描述符,文件描述符和具体文件没有关系,而是为了后面调用epoll而创建的。
4.2.2 控制epoll
epoll_ctl()函数可以向指定的epoll上下文中加入或删除文件描述符。
int epoll_ctl(int epfd, int op, int fd, struct epoo_event *event);
struct epoll_event{
__u32 events;
union{
void *ptr;
__u32 u32;
__u64 u64;
};
};
4.2.3 等待epoll事件
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
等待和指定epoll关联的文件描述符上的事件。
4.2.4 边缘触发事件和条件触发事件
以管道举例,
条件触发是管道被创建可读就触发,边缘触发是管道内被真正写入数据时触发,
默认时条件触发,边缘触发需要不同的编程解决方案。
4.3 存储映射
4.3.1 mmap()
内核提供将文件映射到内存中的接口
void *mmap(void *addr, size_t len, int port, int flags, int fd, off_t offset);
页大小
页是内存管理单元的粒度单位,它是内存中允许具有不同权限和行为的最小单元,页是内存映射的基本块,也是进程地址空间的基本块。
mmap()系统调用的操作单元是页,addr和offset必须按页大小对齐。
获得页大小的可以用:
long sysconf(int name);
4.3.2 munmap()
int munmap(void *addr, size_t len);
取消进程地址空间从addr开始,len字节长的内存中所有页面的映射。
4.3.4 mmap()的优点
相较于read()或write(),避免多余的数据拷贝
不会带来系统调用和上下文切换的开销
当多个进程把同一个对象映射到内存中时,数据会在所有进程间共享
不需要使用系统调用lseek()
4.3.5 mmap()的不足
。。。。
4.3.6 调整映射的大小
void *mremap(void *addr, size_t old_size, size_t new_size, unsigned long flags);
4.3.7 改变映射区域的权限
int mprotect(const void *addr, size_t len, int port);
在某些系统上,mprotect只能操作之前由mmap()所创建的内存区域,在Linux上可以操作任意区域的内存。
4.3.8 通过映射同步文件
int msync(void *addr, size_t len, int flags);
功能等价于系统调用fsync()。
将mmap()生成的映射在内存中的任何修改写回到磁盘中去,从而同步映射中的文件和被映射的文件,如果不调用msync(),是无法保证的映射取消之前,修改过的映射会被写回到硬盘的。
4.3.9 给出映射提示
int madvise(void *addr, size_t len, int advice);
Linux提供系统调用madvise(),进程对自己期望如何访问映射区域给内核一些提示信息。
4.4 普通文件I/O提示
4.4.1 系统调用posix_fadvise()
int posix_fadvise(int fd, off_t offset, off_t len, int advice);
向内核提供在文件fd的[offset, offset+len)区间内的操作提示。
4.4.2 readhead()系统调用
readhead()是Linux特有的,完成和posix_fadvise()使用POSIX_FADV_WILLNEED选项时同样的功能。
4.4.3 “经济实用”的操作提示
通过向内核传递良好的操作提示,很多普通应用的效率可以获得明显提升。
4.5 同步(Synchronized),同步(Synchronous)及异步(Asynchronous)操作
总的来说,同步Synchronous和异步Asynchronous是指I/O操作在返回前是否等待某些事件(如数据存储)返回,而术语同步(Synchronized)和异步Asynchronized则明确指定了某个事件必须发生(如把数据写回硬盘)。
4.6 I/O调度器和I/O性能
单次磁盘查找定位平均需要时长是CPU周期的2500万倍,因为不会每次请求I/O都要求发送给磁盘,系统内核实现I/O调度器来最大程度减少磁盘寻址次数和移动距离。
4.6.1 磁盘寻址
硬盘基于用柱面、磁头和扇区几何寻址方式来获取数据。
柱面指定数据在那个磁道上,一个柱面是所有盘片上离盘中心相同距离的磁道组成的。
磁头表示准确的读写头(即准确的盘片)
再通过扇区找到数据。
现代系统不会直接访问柱面、磁头、扇区这样的概念,而是映射成物理块,使用块号来访问硬盘。
文件系统逻辑块的大小必须是物理块大小的整数倍。
4.6.2 I/O调度器的功能
调度器实现两个基本操作:合并和排序
合并是把多个I/O操作合并为一次I/O操作
排序是选取两个操作中相当更重要的那个。
磁头的移动距离越小,磁头以平滑线性的方式移动,可以改进i/o性能。
4.6.3 改进读请求
按照上面所说:磁头的移动距离越小,磁头以平滑线性的方式移动,可以改进i/o性能。
如果调度器总是以“插入”方式请求进行排队,可能会“饿死”块号值较大的访问请求。
Deadline I/O调度器
有一个标准队列用于按块序号进行排序,还有个读先进先出队列,一个出先进先出队列。
读写FIFO按时间顺序进入队列,因为队首总是停留时间最久的,因此可以以队首的时间作为过期倒计时。
Anticipatory I/O调度器
比deadline I/O多了预测功能,提交一个读操作请求时,调度器等待6毫秒,如果应用程序在6秒内对硬盘同一部分发起另一次读请求,该请求会被立即响应。
CFQ I/O调度器
complete Fair Queuing(完全公平队列),每个进程都有自己的队列,每个队列分配一个时间片,轮询每个队列的i请求。
NOOP I/O调度器
不排序只合并
固态驱动器
如闪存,固态驱动器以类似随机访问内存的方式来索引,没有旋转读写头的操作,不需要排序。
4.6.4 选择和配置你的调度器
/sys/block/hda/queue/scheduler
4.6.5 优化I/O性能
根据I/O调度器的原理,用户空间,应用程序也可以参照调度器的原理,来实现一些读写调度优化。
用户空间I/O调度
按路径排序
同一个目录下的文件,往往在硬盘上的位置更相邻
按inode排序
一个文件可能占用多个物理块,但一个文件只有一个索引节点。可以通过stat()系统调用来获得inode序号
按物理块排序
系统调用通过ioctl(),来通过文件的逻辑块获得物理块
第5章 进程管理
5.1 程序、进程和线程
程序指可运行的二进制代码
进程指运行起来的程序,进程包含二进制镜像,加载到内存中,还涉及很多其他方面。
一个进程可能包含多个线程
5.2 进程ID
进程的唯一标识符,简称pid
空闲进程:当没有其他进程在运行时,内核所运行的进程,pid等于0。启动后内核运行的第一个进程为init进程,pid为1。除非用户显示告诉内核要运行哪个程序,否则内核必须·自己指定合适的init程序。Linux内核会尝试四个可执行文件:
(1)/sbin/init
(2)/etc/init
(3)/bin/init
(4)/bin/sh
5.2.1 分配进程ID
进程ID最大为32768,这是为了和老的16位数来表示的UNIX兼容。可以通过/proc/sys/kernel/pid_max来修改
5.2.2 进程体系
创建新进程的那个进程陈为父进程,新进程称为子进程。每个进程都有个父进程ID号(ppid).
每个进程都属于某个用户和某个组,每个子进程都继承父进程的用户和组。
每个进程都是某个进程组,进程组表示该进程和其他进程的关系,不要混淆为前面用户和组的概念。
5.2.3 pid_t
进程ID的数据类型
5.2.4 获取进程ID和父进程ID
pid_t getpid(void);
pid_t getppid(void);
5.3 运行新进程
在unix中,把程序载入内存和创建新进程的操作是分离的。
5.3.1 exec系统调用
int execl(const char *path, const char *arg, …);
execl会把path所指路径的映射载入内存,替换当前进程的映射。
int ret;
ret = execl(“/bin/vi”, “vi”, NULL);
if (ret==-1)
perror(“execl”);
这段代码遵循了UNIX惯例,用vi作为第一个参数。程序解析arg[0]后,就知道二进制映射文件的名字了。
另一个例子是,如果你想编辑文件/home/kidd/hooks.txt:
int ret;
ret=execl(“bin/vi", “vi”, “/home/kidd/hooks.txt”, NULL);
if (ret==-1)
perror("execl);
调用成功时,会跳转到新的程序入口点,而刚刚运行的代码是不再存在于进程的地址空间中。
exec系的其他函数:
int execlp(const char file, const char arg, …);
int execle(const char *path, const char *arg, …, char * const envp[]);
int execv(const char* path, char* const argv[]);
int execvp(const char* file, char * const argv[]);
int execve(const char *filename, char *const argv[], char *const envp[]);
错误返回值
。。。
5.3.2 fork()系统调用
通过fock()调用,可以创建一个和当前进程映像一样的进程。
pid_t fork(void);
调用成功,回创建一个和调用fork的进程几乎完全一样的进程,这就是子进程和父进程。
最常用的fork()用法是创建一个新的进程,载入新的二进制映像。类似于shell为用户创建一个新进程。新建的子进程会执行一个新的二进制可执行文件的映像。父进程会照常继续运行。
写时复制
在早期UNIX系统中,创建进程很简单,调用fork()时,内核会复制所有内部数据结构、复制进程的页表项,然后把父进程的地址空间按页复制到子进程的地址空间。
在Linux中,采用写时复制的方式,在子进程需要修改的时候才复制,其余时候所有子进程只需要保存一个指向这份资源的指针。
vfork
…
5.4 终止进程
void exit(int status);
5.4.1 终止进程的其他方法
(1)如main()函数返回时会发生的直接跳到结束方式
(2)如果进程收到一个信号,这个信号对应的处理函数是终止进程,进程就会终止;
(3)内核强制终止
5.4.2 atexit()
int atexit(void (*function)(void));
用来注册一些在进程结束时要调用的函数
5.4.3 on_exit()
atexit()的一个同功能函数
int on_exit(void (function)(int,void), void *arg);
5.4.4 SIGCHLD
当一个进程终止时,内核会向其父进程发送sigchld信号。
5.5 等待子进程终止
子进程在父进程之前结束,内核应该把该子进程设置成僵尸进程,等待父进程来查找,父进程获取到已终止的进程状态后,僵尸进程才会正式消失。
Linux内核提供一些接口,可以获取已终止子进程的信息,最简单的一个就是wait(),调用wait成功时,会返回已终止子进程的pid,
pid_t wait(int *status);
status会返回更多信息。
5.5.1 等待特定进程
pid_t waitpid(pid_t pid, int *status, int options);
等待位于指定进程组中的任何子进程退出。
详细略
5.5.2 等待子进程的其他方法
waitid(idtype_t idtype, id_t id, siginfo_t *infop, int options);
等待子进程结束并获取其状态(终止、停止或继续运行);
5.5.3 BSD中的wait3()和wait4()
…
5.5.4 创建并等待新进程
int system(const char* command);
创建新进程并等待它结束—同步创建进程。
5.5.5 僵尸进程
一个进程创建了子进程,那它就有责任去等待子进程,如果父进程不等待子进程的结束,就会产生僵尸进程(幽灵进程),会占用资源。
如果父进程先于子进程结束,子进程的父进程会变成init进程,即pid为1的那个init进程。
5.6 用户和组
有效用户ID的作用是:在检查进程权限过程中使用的用户ID,实际用户ID和保留的用户ID是作为代理或潜在用户ID值,其作用是允许非root进程在这些用户ID之间相互切换,实际用户ID是真正运行程序的有效用户id,保留的用户ID是在执行suid程序前的有效用户id。
5.7 会话和进程组
进程组的主要特征是信号可以发送给进程组中所有进程
会话是一个或多个进程组的集合,实际上,会话的功能和shell差不多,没有谁可以去区分他们。
5.8 守护进程
对于守护进程,有两个基本要求:一是必须作为init进程的子进程运行,一是不与任何控制终端交互。
第6章 高级进程管理
6.1 进程调度
现代操作系统几乎都采用抢占式多任务机制。
对于一个应用,可能有时属于处理器约束型进程,有时属于I/O约束型进程。
6.2 完全公平调度器
完全公平调度器给每个进程分配完全相等的时间片。
公平调度器则按优先级大小,分配不同比例的时间,并且保证最小比例的时间片大小也应该大于等于“最小粒度”时间。
6.3 让出处理器
进程可以主动让出处理器,但是实际上使用的机会很少
6.4 进程优先级
优先级跟nice value成反比
6.5 处理器亲和力(Affinity)
单个系统多个处理器,进程调度器应该让一个进程在一个cpu上运行。
处理器间的进程迁移最大的性能损失来自于“缓存效应”。现代对称多处理器(SMP)系统的设计中,每个处理器的缓存是各自独立的,而且相互不同
6.6 实时系统
对于实时进程,应该小心。如果没有更高优先级的程序抢占,实时程序就会一直运行;
如果还存在着死循环,系统就会失去响应。
要防止其他进程被饿死。
注意避免忙等待,如果一个实时进程忙等待一个较低优先级进程占用的资源,就会一直处于忙等待
在开发实时程序时,应确保一直开着一个终端,以比正在开发的程序更高优先级的方式运行,这样,在紧急情况下,终端会保持响应,可以杀死实时进程。
6.7 资源限制
Linux内核规定了一个进程消耗资源的上限。
第7章 线程
线程是操作系统调度器可以调度的最小执行单元。
7.1 程序、进程和线程
虚拟内存和进程相关,与线程无关;每个进程都有独立的内存空间,进程内的线程共享这份内存空间。
虚拟处理器是线程相关的,与进程无关。每个线程都是可独立调度的实体。
7.2 多线程
由于理解和调试多线程代码非常困难,因此线程模型和同步策略必须从一开始就是系统设计的一部分。
7.3 线程模型
内核线程模型:
每个内核线程直接转换为一个用户空间的线程
用户级线程模型:
优点是上下文切换成本几乎为0,因为应用本身可以决定何时运行哪个线程;但是由于支持线程的内核只有一个,该模型无法利用多处理器,因此无法提供真正的并行性。在Linux上,本身就支持非常低成本的上下文切换,因此带来的好处微乎其微。
混和式线程模型:
把N个用户线程映射到M个内核线程上,但是该模型很难实现。
协同程序提供了比线程更轻量级的执行单元,协同程序更注重于程序流的控制,而不是并发性。Linux本身并不支持协同程序。
7.4 线程模式
两个核心的编程模式是:
每个连接对应一个线程和事件驱动。
I/O是两者之间的很大一个区别
以Web服务为例,每个连接一个线程的模式中,需要很多线程,线程自身的成本是固定的,主要需要内核和用户空间栈,这些固有成本带来了可扩展性的上限。事实上,使用的线程数如果超出系统的处理器个数,并不会给并发带来任何好处。
基于兑一个连接一个线程的讨论,提出事件驱动的线程模式:
通过发送异步I/O请求和使用I/O多路复用来管理服务器中的控制流。在这种模式下,请求处理准换为一系列异步I/O请求及其关联的回调函数。这些回调函数可能会通过I/O多路复用的方式来等待。当返回I/O请求时,事件循环会向等待的线程发送回调。
事件驱动模式是当前设计多线程服务器的最佳选择。
7.5 并发性、并行性和竞争
并发性指种编程模式,并行指多个处理器时同时运行多个线程。
7.6 同步
如果一个操作是不可分割的,就称该操作是原子性的,不能和其他操作交叉。
锁住数据而不是代码
一种简单的死锁方式是ABBA,解决办法是应该总是先获取互斥A,然后获取互斥B;
7.7 Pthreads
join线程
int pthread_join(pthread_t thread, void **retval);
成功调用时,调用线程会被阻塞,直到thread指定的线程终止,如果线程已经终止,会立即返回。一但指定线程终止,线程就会立即醒来。
detach线程
int pthread_detach(pthread_t thread);
调用detach使得进程不可被join,因为线程在被join之前占用的系统资源不会被释放,不想join的线程应该进行detach.
第8章 文件和目录管理
8.1 文件及其元数据
stat获取文件元数据
chmod设置文件权限
chown设置文件的所有者和所属群
8.2 目录
8.2.4 读取目录内容