Skip to content

Latest commit

 

History

History
1740 lines (791 loc) · 52.1 KB

Linux系统编程.md

File metadata and controls

1740 lines (791 loc) · 52.1 KB

Linux系统编程

1、基本概论

系统编程

系统调用

系统编程始于系统调用。系统调用是为了从操作系统获得服务或者资源而从用户空间--如文本编辑器,游戏等等--向内核(系统的核心)发起的函数调用

调用系统调用

用户空间应用程序不可能直接访问内核。基于系统安全和稳定的考虑,用户空间程序禁止直接执行内核的代码或者操作内核数据。相反,内核必须提供这样一个机制,用户空间程序能够发送信号通知内核它希望调用一个系统调用。这个应用程序因此能够通过这一机制深入到内核中,执行那些内核允许执行的代码

文件和文件系统

Linux遵循一切皆是文件的理念。因此,很多的交互工作是通过读取和写入文件来完成,就算问题的目标并不是你日常所想的文件

文件必须被打开才能访问。文件可以以只读方式或者只写方式打开,或者两者兼有。一个打开的文件通过唯一的文件描述符进行引用,该描述符是打开文件的元数据到其本身的映射。在Linux内核中,文件描述符用一个整数表示(C语言中的类型为int),简写为fd文件描述符在用户空间中共享,允许用户程序用文件描述符直接访问文件

普通文件

一个普通文件包含以线性字节数组方式组织的数据,通常称为字节流。文件中的任何字节都可以被读或者写,这些操作开始于特定的字节,也就是文件中所谓的“地址”的概念。这个地址就是文件位置或者文件偏移量。当文件首次打开时,位置为0。通常随着对文件的读写操作(按字节进行),文件的位置也随之增长。文件的位置也可以通过手工指定一个值,就算这个值超过了文件的结尾(在超过文件结尾位置之后写入字节将会导致中间的字总结填充为0)。

不允许在文件的头部之前写入字节。所以文件位置起始于0,它不可能是负数。

在文件中间写入字节将会覆盖位置偏移量上的值

同一个文件能被不同或者相同的进程多次打开,系统为每一个打开文件的实例提供唯一的文件描述符。进程能够共享文件描述符,从而允许同一文件描述符被多个进程使用

文件通过文件名进行访问,但事实上,对于文件本身并不与文件名称直接关联。相反,文件通过inode(信息节点)来访问,inode使用唯一的数值进行标志

目录和链接

通过inode编号来访问文件显然不是一个明智的决定(也是一个潜在的安全漏洞)。因此,人们经常使用文件名来访问文件。目录就是用来提供访问文件时所需的名字的,目录将易读的名字和inode编号进行映射。名字与inode的配对,称为链接

特殊文件

命名管道

命名管道是一种以文件描述符为信道的进程间通信(IPC)机制,通过一种特殊文件进行访问。普通管道是将一个程序的输出以“管道”的方式传送给另一个程序,并作为该程序的输入。它们通过系统调用在内存中创建而不在任何文件系统中存在。命名管道和普通管道一样,但是通过文件进行访问。称为FIFO特殊文件,不相关的进程也可以访问这个文件而进行交互

套接字

套接字是进程间通信中的高级形式,它允许不同进程进行通信,不仅仅是同一台机器,不同机器也可以。事实上,套接字是网络和因特网编程的基础。

进程体系

如果进程终止,它不会立即从系统中移除。相反,内核将在内存中保存进程的部分内容,允许父进程查询该进程终止的状态,这被称为终止进程等待。一旦父进程已经确认它的终止的子进程,子进程就完全的删除了如果一个进程已经终止,但父进程尚未获知它的状态,则称为僵尸进程init进程等待所有的子进程,保证它的子进程不会永久处于僵尸状态

信号

信号是一种单向异步通知机制,信号可能是从内核发送到进程,也可能是从进程到进程,或者进程给自己发送信号

进程间通信

Linux支持的进程间通信机制包括管道、命名管道、信号量、消息队列、共享内存和快速用户空间互斥体

错误处理

在系统编程中,错误通常通过函数的返回值表示,并通过特殊的变量errno来描述。errno变量在<errno.h>中定义如下:

extern int errno;

C库提供了一些函数将errno值转换到对应的文本。这些函数仅仅在错误报告时是有必要的,检测错误和处理错误则可以直接使用预处理器定义和errno进行处理

例如,perror()函数:

#include <stdio.h>
void perror(const char* str); // 向stderr(标准错误输出)打印出以str指向的字符串为前缀,中间一个冒号,然后是errno描述的当前错误所陈述的字符串

2、文件I/O

在对文件进行读写操作前,需要先打开该文件。内核为每个进程维护一个打开文件的列表,该表称为文件表该表由一些叫做文件描述符(常缩写作fds)的非负整数进行索引。列表中的每项均包含一个打开文件的信息。

打开一个文件返回一个文件描述符,而接下来的操作(读写等等)则把文件描述符作为基本参数。

子进程默认会获得一份父进程的文件表拷贝。其中打开文件列表、访问模式,当前文件位置等信息都是一致的。进程中文件表的变化(例如子进程关闭文件)也不会影响其他进程的文件表

每个进程会至少有三个打开的文件描述符:0,1,2,除非进程显示的关闭它们。文件描述符0是标准输入(stdin),文件描述符1是标准输出(stdout),文件描述符2是标准错误(stderr)

open() 系统调用

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

int open (const char *name, int flags);
int open (const char *name, int flags, mode_t mode);

open()系统调用将路径名name给出的文件与一个成功返回的文件描述符相关联,文件位置指针被设定为0,而文件则根据flags给出的标志位打开

open() 的 flags 参数

flags的参数必须是以下之一:O_RDONLY(只读),O_WRONLY(只写),O_RDWR(读写模式)

举个以只读方式打开文件的例子:

int fd;
fd = open ("/home/kidd/madagascar", O_RDONLY);
if (fd == -1)
/* output error info*/

read() 系统调用

#include <unistd.h>
/*
作用:该系统调用从由fd指向的文件的当前位置至多读len个字节到buf中
返回值:成功时,将返回写入buf中的字节数。出错则返回-1,并设置errno
*/
ssize_t read (int fd, void *buf, size_t len);

返回值

如果这个函数返回一个比len小的非0正整数对于read()来说是合法的。出现这种情况,可能有各种各样的原因,例如:可供读取的字节数本来就比len要少,系统调用可能被信号打断,管道可能被破坏(如果fd是个管道),等等。

如果返回0。标志这EOF。没有可以读入的数据

读入所有的字节

如果想要处理所有错误,并且读入所有len个字节(至少读到EOF),那么之前简单的read()是不合适的。为了达到目的,需要一个循环

ssize_t ret;

/* there are some codes */

while (len != 0 && (ret = read(fd, buf, len)) != 0) {
	if (ret == -1) {
		if (errno == EINTR) {
			continue;
		}
		perror("read");
		break;
	}

	len -= ret;
	buf += ret;
}

循环从fd所指的当前文件位置读入len个字节到buf中,读入会一直到读完所有len个字节或者到EOF为止。如果读入了多于0个但少于len个字节,从len中减去已读字节数,buf增加相应数量的字节数,并且重新调用read()

部分读入不仅是合法的,还是常见的

write()系统调用

#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t count);

一个write()调用从由文件描述符fd引用文件的当前位置开始,将buf中至多count个字节写入文件中。成功时,返回写入字节数,并更新文件位置。错误时,返回-1,并将errno设置为相应的值。

部分写

相对于read()的返回部分读的情况,write()不太可能返回一个部分写的结果。而且,对write系统调用来说没有EOF情况。对于普通文件,除非发生一个错误,否则write将保证写入所有的请求。

所以,对于普通文件,不需要进行循环写入了。然后,对于其他类型--例如套接字--大概得有个循环来保证你真的写入了所有请求的字节:

ssize_t ret, nr;

/* there are some codes */

while (len != 0 && (ret = write(fd, buf, len)) != 0) {
	if (ret == -1) {
		if (errno == EINTR) {
			continue;
		}
		perror("write");
		break;
	}

	len -= ret;
	buf += ret;
}

追加模式

fd在追加模式下打开时(通过指定O_APPEND参数),写操作就不从文件描述符的当前位置开始,而是从当前文件末尾开始。

举例来说,假设有两个进程在向同一个文件写。不使用追加模式的话,如果第一个进程向文件末尾,而第二个进程也这么做,那么第一个进程的文件位置将不在指向文件末尾,而将指向文件末尾减去第二个进程写入的字节数的地方。这意味着多个进程如果不进行显示的同步则不能进行追加操作,因为它们存在竞争添加

write()的行为

当一个write()调用返回时,内核已将所提供的缓冲区数据赋值到了内核缓冲区中,但却没有保证数据已写到目的文件。处理器与硬盘的速度差异使这种情况非常明显

当用户空间应用发起write()系统调用时,Linux内核进行几项检查,然后直接将数据拷贝至一个缓存区中。稍后,在后台,内核收集所有这样的“脏”缓冲区,将它们排好序,并写入磁盘上(此过程称为回写)。这使得write调用马上被调用并立刻返回。内核可以将写入操作推迟到空闲阶段,并将很多写操作一起处理。

如果一个read调用希望读取刚刚写到缓冲区中但尚未写入磁盘的数据,请求将从缓存区中响应,而不是读取磁盘上“陈旧”的数据。这种行为实际上提高了效率,因为read只需从内存缓存中读而不用到硬盘中找。在这种情况下,即时对于一个应用程序来讲写操作已经成功了,但数据并没有写入到磁盘。

同步I/O

由于写缓冲提供了巨大的性能改进,以至于一些半吊子的“现代”系统都用缓冲区实现了延迟写。然而,常有应用想要控制数据被写入磁盘的时间。对于这些需要,Linux内核提供了一些选择来允许用性能换取同步操作

fsync() 和 fdatasync()

最简单的确认数据写入磁盘的方法是使用fsync()系统调用:

#include <unistd.h>
int fsync(int fd); // 成功时,返回 0。失败时,返回 -1

调用fsync()可以保证fd对应文件的脏数据回写到磁盘上。文件描述符fd必须是以写方式打开的。

在把数据系融入硬盘缓存时,fsync()是不可能知道数据是否已经在磁盘上了。磁盘可能报告说数据已写入,但数据可能还在磁盘驱动器的缓存上。幸运的是,在磁盘驱动器缓存中的数据将会很快写入到磁盘

/* there are some codes */

int ret;
ret = fsync(fd);
if (ret == -1)
/* there are some error codes */

sync()

sync()系统调用可以用来对磁盘上的所有缓冲区进行同步,尽管其效率不高,但仍然被广泛使用:

#include <unistd.h>
void sync (void);

该函数没有参数,也没有返回值。它总是成功返回,并确保所有的缓存区--包括数据和元数据--都能写入磁盘

O_SYNC 标志

O_SYNC 标志在open()中使用,使所有在文件上的I/O操作同步

int fd;
fd = open (file, O_WRONLY | O_SYNC);
if (fd == -1) {
    perror (”open”);
    return -1;
}

读请求总是同步的。如果不同步,将无法保证读取缓冲区中数据的有效性。write()调用一般是非同步的。调用返回和数据写入磁盘之间没有什么关系。O_SYNC标志则强制将两者关联,从而保证write()调用进行I/O同步。(O_SYNC会在时间上有一定的开销,所以同步I/O一般是在无计可施情况下的最后选择)

关闭文件

close()系统调用将文件描述符和对应的文件解除,分离进程和文件的关联。给定的文件描述符不再有效,内核可以随意将其作为随后的open()creat()调用的返回值而重新使用。

#include <unistd.h>
int close(int fd); // 成功时返回 0,错误时返回 -1,并设置 errno 为相应的值

关闭文件和文件被写入磁盘没什么关系(为什么?)

错误码

一个常见的错误是不检查close()的返回值。这样处理可能会忽略了某个重大的错误。某些操作因为延迟的原因,其错误可能在后来才出现,而close()会报告这些错误。

用lseek()查找

一般的,一个文件中的I/O是线性的,由读写引发的文件位置的隐式更新就是全部需要查找定位的了。

lseek()系统调用能够对给定文件描述符引用的文件位置设定指定值:

#include <sys/types.h>
#include <unistd.h>
off_t lseek(int fd, off_t pos, int origin); // 成功时返回新的文件位置。错误时返回-1并设置errno值

例如,设置文件位置为1825:

off_t ret;
ret = lseek (fd, 1825, SEEK_SET);
if (ret == -1)
/* error */

确定文件当前的位置:

int pos;
pos = lseek (fd, 0, SEEK_CUR);
if (pos == -1)
/* error */
else
/* 'pos' is the current position of fd */

错误码

出错时,lseek()返回 -1, 并将errno设置为下面四个值之一:

EBADF、EINVAL、EOVERFLOW、ESPIPE

定位读写

Linux提供了两种read()write()的变体来代替lseek(),每个调用都以需要读写的文件位置为参数。完成时,不修改文件位置。

读形式的调用:

#define _XOPEN_SOURCE 500
#include <unistd.h>
ssize_t pread(int fd, void *buf, size_t count, off_t pos);

这个调用从文件描述符fdpos文件位置读取count个字节到buf中。

写形式的调用:

#define _XOPEN_SOURCE 500
#include <unistd.h>
ssize_t pwrite(int fd, const void *buf, size_t count, off_t pos);

这个调用从文件描述符fdpos文件位置写count个字节到buf中。

以上这两个调用比直接使用lseek()调用的好处是:避免了任何在使用lseek()时可能出现的潜在竞争。由于线程共享文件描述符,可能在一个线程调用lseek()之后,但尚未进行读写操作前,另一个线程修改文件位置。所以可以使用这两个调用来避免这样的竞争。

I/O多路复用

应用程序常常需要在多于一个文件描述符上阻塞:例如响应键盘输入(stdin)、进程间通信以及同时操作多个文件。

在不使用线程,尤其是独立处理每一个文件的情况下,进程无法在多个文件描述符上同时阻塞。如果文件都处于准备好被读写的状态,同时操作多个文件描述符是没有问题的。但是,一旦在该过程中出现一个未准备好的文件描述符(就是说,如果一个read()被调用,但没有读入数据),则这个进程将会阻塞,不能再操作其他文件。可能阻塞只有几秒钟,但是应用无响应也会造成不好的用户体验。然而,如果文件描述符始终没有任何可用数据,就可能一直阻塞下去。

如果使用非阻塞I/O,应用可以发起I/O请求并返回一个特别的错误,从而避免阻塞。但是,从两个方面来讲,这种方法效率较差。首先,进程需要以某种不确定的方式不断发起I/O操作,直到某个打开的文件描述符准备好进行I/O。其次,如果程序可以睡眠的话将更加有效,可以让处理器进行其他工作,直到一个或更多文件描述符可以进行I/O时再唤醒。

三种I/O多路复用方案

I/O多路复用允许应用在多个文件描述符上同时阻塞,并在其中某个可以读写时收到通知。这时I/O多路复用就成了应用的关键所在。

I/O多路复用的设计遵循一下原则:

1、I/O多路复用:当任何文件描述符准备好I/O时告诉我

2、在一个或更多文件描述符就绪前始终处于睡眠状态

3、唤醒:哪个准备好了?

4、在不阻塞的情况下处理所有I/O就绪的文件描述符

5、返回第一步,重新开始

Linux提供了三种I/O多路复用方案:selectpollepoll

select()

select()系统调用提供了一种实现同步I/O多路复用的机制:

#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>

/*
作用:通知执行了select()的进程哪个Socket或文件可读
返回值:负值:select错误,见ERRORS。 
       正值:某些文件可读写或出错  
       0:等待超时,没有可读写或错误的文件
*/

int select (int n,
            fd_set *readfds,
            fd_set *writefds,
            fd_set *exceptfds,
            struct timeval *timeout
            );

其中fd_setselect机制中提供的一种数据结构,实际上是一long类型的数组,每一个数组元素都能与一打开的文件句柄(不仅是socket句柄,还是其他文件或命名管道或设备句柄)建立联系,建立联系的工作由程序员完成,当调用select()时,由内核根据IO状态修改fd_set的内容,由此来通知执行了select()的进程哪一个socket或文件发生了可读或可写事件

监测的文件描述符可以分为三类,分别等待不同的事件。监测readfds集合中的文件描述符,确认其中是否有可读数据(也就是说,确认好了的文件描述符的读操作可以无阻塞的完成)。监测writefds集合中的文件描述符,确认其中是否有一个写操作可以不阻塞地完成。监测exceptfds中的文件描述符,确认其中是否有出现异常发生或者出现带外数据(这种情况只适用于套接字)。指定的集合可能为空(NULL)。相应的,select()则不对此类事件进行监测。

成功返回时,每个集合只包含对应类型的I/O就绪的文件描述符。举个例子,readfds集合中有两个文件描述符:7和9.当调用返回时,如果7还在集合中,该文件描述符就准备好进行无阻塞I/O了。如果9已不在集合中,它可能在被读取时会发生阻塞。出现错误返回-1

第一个参数n,等于所有集合中文件描述符的最大值加1。这样,select()的调用者需要找到最大的文件描述符值,并将其加1后传给第一个参数。

timeout参数是一个指向timeval结构体的指针,定义如下:

#include <sys/time.h>
struct timeval {
    long tv_sec; /* seconds */
    long tv_usec; /* microseconds */
};

如果这个参数不是NULL,即使此时没有文件描述符处于I/O就绪状态,select()调用也将在tv_sec秒、tv_usec微秒后返回。即 select在timeout时间内阻塞,超时时间之内有事件到来就返回了,否则在超时后不管怎样一定返回

如果时限中的两个值都是0,调用会立即返回,并报告调用时所有事件对应的文件描述符均不可用,且不等待任何后续事件。

若将NULL以形参传入,即不传入时间结构,就是将select**置于阻塞状态,**一定等到监视文件描述符集合中某个文件描述符发生变化为止

返回值和错误码

成功时,select()返回在所有三个集合中I/O就绪的文件描述符的数目。如果给出了时限,返回值可能为0.错误时返回 -1,而且errno被设置为下列值之一:EBADFEINTREINVALENOMEM

代码例子
#include <stdio.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>

#define TIMEOUT 5 /* 选择超时秒数 */
#define BUF_LEN 1024 /* 读缓冲区字节 */

int main(int argc, char const *argv[])
{
    struct timeval tv;
    fd_set readfds;
    int ret;
     
    /* 等待输入 */
    FD_ZERO(&readfds); // 把writefds集合中的所有文件描述符移除
    FD_SET(STDIN_FILENO, &readfds); // 向writefds集合中添加文件描述符STDIN_FILENO。STDIN_FILENO就是标准输入设备(一般是键盘)的文件描述符。它的值为0

    /* 设置等待为5秒 */
    tv.tv_sec = TIMEOUT;
    tv.tv_usec = 0;

    /* 在指定的tv时间内阻塞 */
    ret = select(STDIN_FILENO + 1, &readfds, NULL, NULL, &tv); // 通知执行了select()的进程哪个Socket或文件可读

    if (ret == -1) {
    	perror("select");
    	return 1;
    }
    else if (!ret) {
    	printf("%d 秒 已经过去了. \n", TIMEOUT);
    	return 0;
    }

    if (FD_ISSET(STDIN_FILENO, &readfds)) { // 测试给定的文件描述符在不在给定的集合中。检查fdset联系的文件句柄fd是否可读写,当>0表示可读写
    	char buf[BUF_LEN + 1];
    	int len;
    	/* 保证没有阻塞 */
    	len = read(STDIN_FILENO, buf, BUF_LEN);
    	if (len == -1) {
    		perror("read");
    		return 1;
    	}
    	if (len) {
    		buf[len] = '\0';
    		printf("read: %s\n", buf);
    	}
    	return 0;
    }
    fprintf(stderr, "This should not happen!\n");

	return 0;
}

poll()

#include <sys/poll.h>
/*
返回值:成功时,返回具有非零revents字段的文件描述符个数
       超时前没有任何事件发生则返回零
       失败时返回-1,并且设置 errno
*/
int poll(struct pollfd *fds, unsigned int nfds, int timeout);

select()使用的三个基于位掩码的文件描述符集合不同,poll()使用一个简单的nfdspollfd结构体数组,fds指向该数组。结构体定义如下:

#include <sys/poll.h>
struct pollfd {
    int fd; /* 文件描述符 */
    short events; /* 请求观看的事件 */
    short revents;
};

每个pollfd结构体指定监视单一的文件描述符。可以传递多个结构体,使得poll()监视多个文件描述符。每个结构体的events字段是要监视的文件描述符事件的一组位掩码。用户设置这个字段。revents字段则是发生在该文件描述符上的事件的位掩码。内核在返回时设置这个字段。所有在events字段请求的事件都可能在revents字段中返回。下面是合法的事件:

POLLIN    有数据可读
POLLRDNORM    有普通数据可读
POLLRDBAND    有优先数据可读
POLLPRI    有高优先级数据可读
POLLOUT    写数据不会阻塞
POLLWRNORM    写普通数据不会阻塞
POLLBAND    写优先数据不会阻塞
POLLMSG    有一个SIGPOLL信息可用

另外,如下事件可能在revents中返回:

POLLER    给出文件描述符上有错误
POLLHUP    文件描述符上有挂起事件
POLLNVAL    给出的文件描述符非法

这三个在events中没有意义,而总是在合适时返回。

timeout参数指定在任何I/O就绪前需要等待时间的长度,以毫秒计。负值表示永远等待。一个零值表示调用立即返回,不阻塞,列出所有未准备好的I/O,但不等待任何其他事件。这种情况下,poll()就如同其名,轮询一次后立即返回。

poll()和select()的区别

poll和select没有本质上的区别(完成一样的工作),它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd

poll还有一个特点是“水平触发”,如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd。 poll与select的不同,通过一个pollfd数组向内核传递需要关注的事件,故没有描述符个数的限制,pollfd中的events字段和revents分别用于标示关注的事件和发生的事件,故pollfd数组只需要被初始化一次。 poll的实现机制与select类似,其对应内核中的sys_poll,只不过poll向内核传递pollfd数组,然后对pollfd中的每个描述符进行poll,相比处理fdset来说,poll效率更高poll返回后,需要对pollfd中的每个元素检查其revents值,来得指事件是否发生。

优点

1、poll() 不要求开发者计算最大文件描述符加一的大小。 2、poll() 在应付大数目的文件描述符的时候速度更快(相比于select),例如:用select()监视值为900的文件描述符--内核需要检查每个集合中的每个比特位,直到第900个。 3、它没有最大连接数的限制,原因是它是基于链表来存储的。

缺点

1、大量的fd的数组被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义。 2、与select一样,poll返回后,需要轮询pollfd来获取就绪的描述符

代码例子
#include <stdio.h>
#include <unistd.h>
#include <sys/poll.h>

#define TIMEOUT 5

int main(int argc, char const *argv[])
{
	struct pollfd fds[2];
	int ret;

    fds[0].fd = STDIN_FILENO;
    fds[0].events = POLLIN;

    fds[1].fd = STDOUT_FILENO;
    fds[1].events = POLLOUT;

    ret = poll(fds, 2, TIMEOUT * 1000);

    if (-1 == ret) {
    	perror("poll");
    	return 1;
    }
    if (!ret) {
    	printf("%d 秒过去了\n", TIMEOUT);
    	return 0;
    }
    if (fds[0].revents & POLLIN) {
    	printf("stdin is readable\n");
    }
    if (fds[1].revents & POLLOUT) {
    	printf("stdout is writeable\n");
    }

	return 0;
}

内核内幕

当一个应用发起一个read()系统调用,就开始一段奇妙的旅程。C库提供了系统调用的定义,而在编译器调用转化为适当的陷阱态。当一个用户空间进程转入内核态,则转交系统调用处理器处理,最终交给read()系统调用,内核确认文件描述符所对应的对象类型。然后内核调用与相关类型对应的read()函数。对文件系统而言,这个函数是文件系统代码的一部分。然后该函数继续其工作--举例来说,从文件系统中读取数据--并把数据返回个用户空间的read()调用,该调用返回复制数据到用户空间的系统调用处理器,然后将数据复制到用户空间,最后read()系统调用返回而进程继续执行。

页缓存

时间局部性

页缓存是一种在内存中保存最近在磁盘文件系统上访问过的数据的方式。相对于现在的处理器速度而言,磁盘访问速度过慢。在内存中保存被请求数据,内核在接下来对相同的后序请求可以直接从内存中读取,尽量避免重复磁盘访问。

页缓存是内核寻找文件系统数据的第一目的地。只有缓存中找不到时内核才会调用存储子系统从磁盘中读取数据。当数据第一次读取后,就会从磁盘读入页缓存中,并从缓存中返回给应用。如果那项数据被再次读取,就直接从缓存中返回。

Linux页缓存大小是动态的。随着I/O操作将越来越多的数据带入内存,页缓存也随之增大,消耗掉空闲的内存。如果页缓存最终确实消耗掉了所有的空闲内存,而且有新增的存储要求出现,页缓存就会被削减,释放它最少使用的页,将空间让给“真正的”内存使用。这种处理是自动进行的。一个动态变化的缓存允许Linux使用所有的系统内存,并缓存尽可能多的数据。

向磁盘交换一块很少使用的数据,比从页缓存中清除掉一条常常使用的且很可能将在下次重读中使用的数据更有意义。

空间局部性

基于这个原理,内核实现了页缓存预读技术。预读是在每次请求时从磁盘数据中读取更多的数据到页缓存中的动作--多读一点点会很有效。当内核从磁盘读取一块数据时,也会读取接下来一两块数据。一次读取较大的连续数据块时磁盘不需要进程寻道,所以会比较有效。

内核管理预读

内核管理预读是动态的。如果它注意到一个进程持续使用预读的数据,内核就会增加预读窗口,因而预读进更多的数据。预读窗口最小为16KB,最大128KB。反之,如果内核发现预读没有造成任何有用的命中--就是说,应用在文件中来回查找而不是连续的读--它可以完全关闭预读。

页回写

内核使用缓冲区来延迟写操作。当一个进程发起写请求,数据被拷贝进一个缓冲区,并将该缓冲区标记为“脏”的,这意味着内存中的拷贝要比磁盘上的新。此时,写请求就可以返回了。如果对同一个数据块有新的写请求,缓冲区就更新为新数据(此时还是叫作脏数据)。在该文件其他部分的写请求则开辟新的缓冲区。

最终那些“脏”缓冲区需要写入磁盘,将磁盘文件和内存数据同步。这就是所谓的回写。以下两个条件会触发回写:

1、当空闲内存小于设定的阈值时,脏的缓冲区就会回写到磁盘上,被清理的缓冲区可能会被移除,来释放内存空间

2、当一个脏的缓存区寿命超过设定的阈值时,缓冲区被回写至磁盘。以此来避免数据的不确定性。

回写由一些叫做pdflush的内核线程操作。当以上两种情况之一出现时,pdflush线程被唤醒,并开始将脏的缓冲区提交到磁盘,直到没有触发条件被满足。

3、缓冲输入输出

作为文件系统的抽象,是 I/O中最基本的概念--所有的磁盘操作都是基于块进行的。因此,当请求块以块大小整数倍对齐地址时,I/O效率是最理想的(可以防止无关的内核操作)(这是因为内核和硬件之间是通过块交互的)。

但问题是程序很少以块为单位进行操作。程序往往以区域、行、单个字符为单位进行操作,而不是抽象的块。所以,为了改善这种情况,程序使用用户缓冲I/O。

用户-缓冲I/O

需要对普通文件执行许多轻量级I/O请求的程序通常使用用户缓冲I/O。用户缓冲I/O是在用户空间而不是在内核中完成的。

当数据被写入时,它会被存储在程序地址空间的缓冲区中。当缓冲区规模达到一个给定的值(缓冲区大小时),整个缓冲区会在一次操作中被写出。同理,读操作一次读入缓冲区大小且块对齐的数据。当应用程序执行不对齐的读请求时,缓冲区一块一块的给出数据。最后,当缓冲区为空时,另一个大的块对齐的区域又被读入。

标准I/O库

这个库可以提供健壮而且强大的用户缓冲方案(这样用户就不用手动去设置缓冲)

文件指针

标准I/O例程并不直接操作文件描述符。取而代之的是它们用自己唯一的标识符,即大家熟知的文件指针。在C标准库里,文件指针映射到一个文件描述符。文件指针有FILE类型的指针表示(FILE类型定义在<stdio.h>中)。

在标准I/O中,一个打开的文件叫做“流”。“流”可以被打开用来读(输入流),写(输出流),或者二者兼有(输入输出流)。

打开文件

#include <stdio.h>
/*
作用:让文件和一个新的流关联

返回值:FILE指针

参数2:描述以怎样的方式打开指定文件:r  r+  w  w+  a  a+
*/
FILE* fopen(const char * path, const char * mode);

通过文件描述符打开文件

#include <stdio.h>
/*
作用:将一个已经打开的文件描述符(fd)转成一个流
*/
FILE * fdopen (int fd, const char *mode);

一旦一个文件描述符被转换为一个流,则不应该直接在该文件描述符上进行I/O(尽管这么做是合法的)。需要注意的是文件描述符没有被复制,而只是关联了一个新的流。关闭流也会关闭相应的文件描述符

关闭流

#include <stdio.h>
int fclose(FILE *stream);

所有被缓冲但还没有被写入的数据会先被写出。

关闭所有的流

#define _GNU_SOURCE
#include <stdio.h>
int fcloseall (void);

关闭所有和当前进程相关联的流,包括标准输入、标准输出、标准错误

从流中读取数据

单字节读取

#include <stdio.h>
int fgetc(FILE *stream);

这个函数从流中读取下一个字符并把无符号字符强转为int返回。强转是为了有足够的范围来表示文件结束符和错误;在这种情况下EOF会被返回。fgets()的返回值必须以int型保存。如果要打印这个字符,那么就再把int型转化为char型:

int c;
c = fgetc(stream);

if (c == EOF)
    /* error */
else
    printf("c=%c\n", (char) c);

按行读取

#include <stdio.h>
/*
返回值:成功时,返回str;失败时,返回NULL
*/
char* fgets(char *str, int size, FILE *stream);

这个函数从流中读取size - 1个字节的数据,并把数据存入str中。当所有字节读入时,空字符被存入字符串末尾。当读到EOF或换行符时读入结束(读到换行符时,'\n'会被存入str中)。

char buf[LINE_MAX];
if (!fgets (buf, LINE_MAX, stream))
    /* error */

读取二进制文件

#include <stdio.h>
size_t fread(void *buf, size_t size, size_t nr, FILE *stream);

调用fread()会从输入流中读取nr个数据,每个数据有size个字节,并将数据放入到buf所指向的缓冲区。文件指针向前移动读出数据的长度。读入数据的个数(不是读入字节的个数)被返回

char buf[64];
size_t nr;
nr = fread (buf, sizeof(buf), 1, stream);
if (nr == 0)
    /* error */

向流中写数据

所有的机器设计都有数据对齐的要求。程序员倾向于把内存想象成一个简单的字节数组。但是处理器并不以字符大小对内存进行读写。相反,处理器以特定的粒度(例如2,4,8或16字节)来访问内存。因为每个处理的地址空间都从0地址开始,进程必须从一个特定粒度的整数倍开始读取。因此,C变量的存储和访问都要是地址对齐的。总的来说,变量是自动对齐的,这指的是和C数据类型大小相关的对齐。例如,一个32位整数以4个字节对齐。用另一种说法就是一个int需要被存储在能被4整除的内存地址中。访问不对齐的数据在不同的体系结构上有不同程度的性能损失。一些处理器能够访问不对齐的数据,但是会有一个很大性能损失。有些处理器根本不能访问非对齐的数据,而且企图这么做会导致硬件异常。更糟糕的是,一些处理器为了强制地址对齐会丢弃低位的数据,从而导致不可预料的行为。通常,编译器会自动帮我们对齐数据,而且对齐是程序员不可见的。在处理结构体,手动执行内存管理,向磁盘存储二进制数据,进行网络通信时,对齐都非常重要。

写入单个字符

#include <stdio.h>
int fputc (int c, FILE *stream);
if (fputc (’p’, stream) == EOF)
    /* error */

写入字符串

#include <stdio.h>
int fputs (const char *str, FILE *stream);
stream = fopen (”journal.txt”, ”a”);
if (!stream)
    /* error */
if (fputs (”The ship is made of wood.\n”, stream) == EOF)
    /* error */
if (fclose (stream) == EOF)
    /* error */

定位流

#include <stdio.h>
int fseek(FILE *stream, long offset, int whence);

如果whence被设置为SEEK_SET,文件的位置被设置到offset处。如果whence被设置为SEEK_CUR,文件位置被设置到当前位置加上offset。如果whence被设置为SEEK_END,文件位置被设置到文件末尾加上offset

#include <stdio.h>
int fsetpos(FILE *stream, fpos_t *pos);

这个函数将流的位置设置到pos处。

获得当前流位置

#include <stdio.h>
long ftell(FILE *stream);
#include <stdioh.h>
int fgetpos(FILE *stream, fpos_t *pos);

清洗流

将用户缓冲区写入内核,并且保证所有的数据都通过write()写出。

#include <stdio.h>
/*
返回值:成功时返回0。失败时返回EOF,并且设置相应的errno
*/
int fflush(FILE *stream);

调用该函数时,stream指向的流中的所有未写入的数据会被清洗到内核中。如果stream是空的(NULL),所有进程打开的流会被清洗掉。

用户缓冲区和内核缓冲区

本章提到的所有调用的缓冲区都是通过C函数库来维护的,它们保留在用户空间,而不是内核空间。程序保留在用户空间中,并且运行用户的代码,不执行系统调用。只有当磁盘或其他介质必须被访问时系统调用才会被执行。

控制缓冲

不缓冲

没有执行用户缓冲。数据直接提交到内核。因为这和用户缓冲对立,这个选项通常不用。标准错误默认是不缓冲的。

行缓冲

缓冲以行为单位执行。每当遇到换行符,缓冲区被提交到内核。行缓冲对输出到屏幕的流有用。因此,它是终端的默认缓冲方式(标准输出默认行行为)

块缓冲

缓冲以块为单位执行。它适用于文件。默认的所有和文件相关的流都是块缓冲的。标准I/O块缓冲称为全缓冲。

线程安全

线程就是在同一个进程中执行的多个实例。如果不采取数据同步措施或将数据线程私有化,线程可以任何时间修改共享数据。支持线程的操作系统提供加锁机制(保证相互排斥的程序结构)来保证线程不会互相干扰。

标准I/O的函数本质上是线程安全的。在内部实现中,设置了一把锁,一个锁计数器,和为每个打开的流创建的所有者线程。一个线程要想执行任何I/O请求,必须首先获得锁而且成为所有者线程。两个或多个运行在同一个流上的线程不能交错地执行I/O操作,因此,在单独一个函数调用的上下文中,标准I/O操作是原子的

手动文件加锁

flockfile()

函数flockfile()等待流被解锁,然后获得锁,增加锁计数,成为流的所有者线程,然后返回:

#include <stdio.h>
void flockfile(FILE *stream);
funlockfile()

函数funlockfile()减少与流相关的锁计数:

#include <stdio.h>
void funlockfile(FILE *stream);

如果锁计数达到了0,当前的线程放弃流的所有权,另一个线程现在能够获得锁。

/*
中间那三行代码就是原子操作区域,在一个线程进入这个临界区的时候,其他线程不能进入
*/
flockfile(stream);
fputs("List of treasure:\n", stream);
fputs("(1) 500 gold coins\n", stream);
fputs("(2) Wonderfully ornate dishware\n", stream);
funlockfile(stream);
ftrylockfile()
#include <stdio.h>
int ftrylockfile(FILE *stream);

这个函数是非阻塞版的flockfile()。如果流当前加了锁,ftrylockfile()不做任何处理,并立即返回一个非零值。如果流当前没有加锁,它获得锁,增加锁计数,成为流的所有组线程,并且返回0。

4、高级文件I/O

Event Poll接口(epoll)

poll()select()每次调用时都需要所有被监听的文件描述符。内核必须遍历所有被监听的文件描述符。当这个表变得很大时--包含上百,甚至上千个文件描述符时--每次调用时的遍历就成为了明显的瓶颈。

epoll把监听注册从实际监听中分离出来,从而解决了这个问题。一个系统调用初始化一个epoll上下文,另一个从上下文中加入或删除需要监视的文件描述符,第三个执行真正的事件等待。

创建一个新的epoll实例

#include <sys/epoll.h>
int epoll_create(int size)

epoll_create创建一个epoll实例,返回与该实例关联的文件描述符。这个文件描述符和真正的文件没有关系,仅仅是为了后续调用使用epoll而创建的。size参数告诉内核需要监听的文件描述符数目,但不是最大值。

int epfd;
epfd = epoll_creat(100); /* plan to watch ~100 fds */
if (epfd < 0)
    perror("epoll_create");

控制 epoll

#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

epoll_ctl成功调用将关联epoll实例和epfd。参数op指定对fd要进行的操作。event参数描述epoll更具体的行为。

参数op的有效值:

EPOLL CTL ADD :把fd指定的文件添加到epfd指定的epoll实例监听集中,监听event中定义的事件。

EPOLL CTL DEL :把fd指定的文件从epfd指定的epoll监听集中删除。

EPOLL CTL MOD :使用event改变在已有fd上的监听行为。

头文件<sys/epoll.h>中定义了epoll_event结构体:

struct epoll_event {
    __u32 events; /* events */
    union {
        void *ptr;
        int fd;
        __u32 u32;
        __u64 u64;
    } data;
};

其中的events参数表示在给定文件描述符上监听的事件(多个事件可以用位或运算符同时指定):

EPOLLERR、EPOLLET、EPOLLHUP、EPOLLIN、EPOLLONESHOT、EPOLLOUT、EPOLLPRI

其中的data字段由用户使用。确认监听事件后,data会被返回给用户。通常将event.data.fd为设定fd,这样就知道哪个文件描述符触发事件。

等待epoll事件

#include <sys/epoll.h>
/*
作用:等待epoll实例epfd中的文件fd上的事件
返回值:事件数

参数2:events指向包含epoll_event结构体(该结构体描述了每个事件)的内存
参数3:最多可以有maxevents个事件
参数4:时限为timeout毫秒(如果timeout为0,即使没有事件发生,调用也立即返回,此时调用返回0。如果timeout为-1,调用将一直等待到有事件发生)
*/
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

代码例子:

#include <stdio.h>
#include <malloc.h>
#include <sys/epoll.h>

#define MAX_EVENTS 64

int main(int argc, char const *argv[])
{
    struct epoll_event *events;
    int nr_events, i, epfd;
    events = malloc(sizeof(struct epoll_event) * MAX_EVENTS);
    if (!events) {
    	perror("malloc");
    	return 1;
    }
    nr_events = epoll_wait(epfd, events, MAX_EVENTS, -1);

    if (nr_events < 0) {
    	perror("epoll_wait");
    	free(events);
    	return 1;
    }
    for (i = 0; i < nr_events; i++) {
    	printf("events=%d on fd=%d\n", events[i].events, events[i].data.fd);
    }

    free(events);

	return 0;
}

存储映射

除了标准文件I/O,内核提供了另一种高级的I/O方式,允许应用程序将文件映射到内存中,即内存和文件中数据是一一对应的。程序员可以直接通过内存来访问文件,就像操作内存的数据块一样,甚至可以写入内存数据区,然后通过透明的映射机制将文件写入磁盘。

mmap()

#include <sys/mman.h>
/*
作用:请求内核将fd表示的文件从offset处开始的len个字节数据映射到内存中。如果包含了addr,表明优先使用addr为内存中的开始地址。flags指定了其他的操作行为

返回值:成功时,返回映射区的地址;失败时,返回MAP_FAILED,并设置相应的errno

参数1:告诉内核映射文件的最佳地址。这仅仅是提示,而不是强制,大部分用户传递0。调用返回内存映射区域的开始地址
参数3:描述了对内存区域所请求的访问权限:
       PROT_READ  页面可读
       PROT_WRITE  页面可写
       PROT_EXEC  页面可执行
参数4:描述了映射的类型和一些行为:MAP_FIXED、MAP_PRIVATE(映射区不共享)、MAP_SHARED(和所有其他映射该文件的进程共享映射内存)
*/
void* mmap(void *addr, size_t len, int prot, int flags, int fd, off_t offset);

当映射一个文件描述符的时候,描述符引用计数增加。如果映射文件后关闭文件,你的进程依然可以访问该文件。当你取消映射或者进程终止时,对应的文件引用计数会减1。

void *p;
p = mmap (0, len, PROT_READ, MAP_SHARED, fd, 0);
if (p == MAP_FAILED)
    perror ("mmap");

页大小

页是内存中允许具有不同权限和行为的最小单元。因此,页是内存映射的基本块,同时也是进程地址空间的基本块。

mmap()调用操作页。addroffset参数都必须按页大小对齐。也就是说,它们必须是页大小的整数倍。所以映射区域是页的整数倍。如果len参数不能按页对齐,那么就延伸到下个空页。多出来的内存用0填充。

获取页大小

第一中方式:

#include <unistd.h>
long sysconf(int name);
long page_size = sysconf(_SC_PAGESIZE);

第二种方式(不推荐):

int page_size = PAGE_SIZE;

munmap()

#include <sys/mman.h>
/*
作用:移除进程地址空间从addr开始,len字节长的内存中的所有页面的映射  [addr, addr + len]

参数1:上次mmap()调用的返回值
参数2:和上次mmap()调用的那个len相同
*/
int munmap(void *addr, size_t len);

mmap()优点

相对于read()write(),使用mmap()处理文件有很多优点:

1、使用read()write()系统调用需要从用户缓冲区进行数据读写,而使用映射文件进行操作,可以避免多余的数据拷贝。

2、除了潜在的页错误,读写映射文件不会带来系统调用和上下文切换的开销。就像直接操作内存一样简单

3、当多个进程映射同一个对象到内存中,数据在进程间共享。只读和写共享的映射在全体中都是共享的;私有可写的尚未进行写时拷贝的页是共享的。

4、在映射对象中搜索只需要一般的指针操作。而不必使用lseek()

在处理大文件(浪费的空间只占很小的比重),或者在文件大小恰好被page大小整除时(没有空间浪费)优势很明显。

使映射机制同步文件

#include <sys/mman.h>
/*
作用:将mmap()生成的映射在内存中的任何修改回写到磁盘,达到同步内存的映射和被映射的文件的目的。

参数1:必须是页对齐的,通常是上次mmap()调用的返回值
*/
int msync(void *addr, size_t len, int flags);

5、高级进程管理

进程调度

进程调度是内核中决定哪个进程可以运行的组件,换句话说,进程调度器简称--调度器--是把有限的处理器资源分配给进程的内核子系统。

就绪进程是非阻塞的。一个就绪进程还必须至少有部分“时间片”(调度器分配给进程的运行时间)。内核用一个就绪队列维护所有的就绪进程,一旦某进程耗光它的时间片,内核就将其移出队列,直到所有就绪进程都耗光时间片才考虑将其放回队列。

如果只有一个就绪进程,调度器是没有意义的。只有在进程数多于处理器时,调度器才能体现它的价值

一个操作系统能在单处理机上交错地运行多个进程,让人感到似乎同时运行多个进程,就称该操作系统是“多任务”的。在多处理机上,多任务操作系统允许进程在不同处理器上并行执行

多任务操作系统分为两大类:协同式和抢占式。

Linux实现了后一种形式的多任务,调度器可以要求一个进程停止运行,处理器转而运行另一个进程。这种中止的进程的行为称作抢占。

在协同多任务系统中,一个进程持续运行直到它自发停止。我们称进程自发停止的行为为让出。

线程

线程是进程中运行的单元,所有的进程都至少有一个线程。每一个线程都独自占有一个虚拟处理器:独自的寄存器组,指令计数器和处理器状态。虽然多数进程都只有一个线程,但是进程实际可以拥有很多线程,每个线程完成不同的任务,但是共享同一地址空间(也就是同样的动态内存,映射文件,目标代码等等),打开的文件队列和其他内核资源。

6、内存管理

进程地址空间

Linux将它的物理内存虚拟化。进程并不能直接在物理内存上寻址,而是由Linux内核为每个进程维护一个特殊的虚拟地址空间。这个地址空间是线性的,从0开始,到某个最大值。

页和页面调度

虚拟空间由许多页组成。

一个进程不能访问一个处于二级存储中的页,除非这个页和物理内存中的页相关联。

共享和写时复制

虚拟内存中的多个页面,甚至是属于不同进程的虚拟地址空间,也有可能被映射到同一个物理页面。这样允许不同的虚拟地址空间共享物理内存上的数据。共享的数据可能是只读的,或者是可读可写的。

存储区域

内核将具有某些相同特征的页组织成块(blocks),例如读写权限。这些块叫做存储器区域,段,或者映射。

每个进程都可以见到的存储区域:

文本段

包含着一个进程的代码,字符串,常量和一些只读的数据。在Linux中,文本段被标记为只读,并且直接从目标文件(可执行文件或是库文件)映射到内存中。

堆栈段

包括一个进程的执行栈,随着栈的深度动态的伸长或收缩。执行栈中包括了程序的局部变量和函数的返回值

数据段(堆)

又叫堆,包含一个进程的动态存储空间这个段是可写的,而且它的大小是可以变化的。这部分空间往往是由malloc分配的。

BBS段

包含了没有被初始化的全局变量。这些变量根据不同的C标准都有特殊的值(通常来说都是0)

映射文件

大多数地址空间含有很多映射文件,比如可执行文件自己,C或是其他的可链接库和数据文件。一个进程里面有很多的映射文件。

动态内存分配

动态内存是在进程运行时才分配的,而不是在编译时就分配好了的,而分配的大小也只有在分配时才确定。

调整已分配内存大小
#include <stdlib.h>
void *realloc(void *ptr, size_t size);

成功调用realloc()ptr指向的内存区域的大小变为size字节。它返回一个指向新空间的指针,当试图扩大内存块的时候返回的指针可能不再是ptr如果realloc不能在已有的空间上增加到size大小,那么就会另外申请一块size大小的空间,将用来的数据拷贝到新空间中,然后再将旧的空间释放

动态内存释放

自动内存分配,当栈不在使用,空间被自动释放。与之不同的是,动态内存将永久占有一个进程地址空间的一部分,直到它被显式地释放。(当然,当整个进程都退出的时候,所有动态和静态的存储器都荡然无存了)

基于栈的分配

栈是用来存放程序中的字段变量的

#include <alloca.h>
void* alloca(size_t size);

如果分配失败,表明栈溢出了。

用法和malloc()一样,但是不必(实际上是不能)释放分配到的内存。