作者:【美】Robert Love著

2.3 调用write()写

写文件,最基础最常见的系统调用是write()。和read()一样,write()也是在POSIX.1中定义的:

activiyt onResume调用两次 调用两次write方法_缓存

write()调用会从文件描述符fd指向的文件的当前位置开始,将buf中至多count个字节写入到文件中。不支持seek的文件(如字符设备)总是从起始位置开始写。

write()执行成功时,会返回写入的字节数,并更新文件位置。出错时,返回-1,并设置errno值。调用write()会返回0,但是这种返回值没有任何特殊含义,它只是表示写入了零个字节。

和read()一样,write()调用的最基本用法也很简单:

activiyt onResume调用两次 调用两次write方法_文件描述符_02

还是和read()一样,以上这种用法不太正确。调用方还需要检查各种“部分写(partial write)”的场景:

activiyt onResume调用两次 调用两次write方法_文件描述符_03

2.3.1 部分写(Partial Write)
和read()调用的部分读场景相比,write()调用不太可能会返回部分写。此外,write()系统调用不存在EOF的场景。对于普通文件,除非发生错误,write()操作保证会执行整个写请求。

因此,对于普通文件,不需要执行循环写操作。但是,对于其他的文件类型,比如socket,需要循环来保证写了所有请求的字节。使用循环的另一个好处是第二次调用write()可能会返回错误值,说明第一次调用为什么只执行了部分写(虽然这种情况并不常见)。以下是write()调用示例代码:

activiyt onResume调用两次 调用两次write方法_缓存_04

2.3.2 Append(追加)模式
当以Append模式(参数设置O_APPEND)打开文件描述符时,写操作不是从文件描述符的当前位置开始,而是从当前文件的末尾开始。

举个例子,假设有两个进程都想从文件的末尾开始写数据。这种场景很常见:比如很多进程共享的事件日志。刚开始,这两个进程的文件位置指针都正确地指向文件末尾。第一个进程开始写,如果不采用Append模式,一旦第二个进程也开始写,它就不是从“当前”文件末尾开始写,而是从“之前”文件末尾(刚开始指向的文件末尾,即第一个进程开始写数据之前)开始写。这意味着如果缺乏显式的同步机制,多个进程由于会发生竞争问题,不能同时向同一个文件追加写。

Append模式可以避免这个问题。它保证了文件位置指针总是指向文件末尾,因此即使存在多个写进程,所有的写操作还是能够保证是追加写。Append模式可以理解成在每次写请求之前的文件位置更新操作是个原子操作。更新文件位置,指向新写入的数据末尾。这和下一次write()调用无关,因为更新文件位置是自动完成的,但如果由于某些原因下一次执行的是read()调用,那会有些影响。

Append模式对于某些任务很有用,比如之前提到的日志文件更新,但对其他很多操作意义不大。

2.3.3 非阻塞写
以非阻塞模式(参数设置O_NONBLOCK)打开文件,当发起写操作时,系统调用write()会返回-1,并设置errno值为EAGAIN。请求可以稍后重新发起。一般而言,对于普通文件,不会出现这种情况。

2.3.4 其他错误码
其他值得注意的errno值包括:

EBADF

给定的文件描述符非法或不是以写方式打开。

EFAULT

buf指针指向的位置不在进程的地址空间内。

EFBIG

写操作将使文件大小超过进程的最大文件限制或内部设置的限制。

EINVAL

给定文件描述符指向的对象不支持写操作。

EIO

底层I/O错误。

ENOSPC

给定文件描述符所在的文件系统没有足够的空间。

EPIPE

给定的文件描述符和管道或socket关联,读端被关闭。进程还接收SIGPIPE信号。SIGPIPE信号的默认行为是终止信号接收进程。因此,只有当进程显式选择忽略、阻塞或处理该信号时,才会接收到该errno值。

2.3.5 write()大小限制
如果count值大于SSIZE_MAX,调用write()的结果是未定义的。

调用write()时,如果count值为零,会立即返回,且返回值为0。

2.3.6 write()行为
当write()调用返回时,内核已经把数据从提供的缓冲区中拷贝到内核缓冲区中,但不保证数据已经写到目的地。实际上,write调用执行非常快,因此不可能保证数据已经写到目的地。处理器和硬盘之间的性能差异使得这种情况非常明显。

相反,当用户空间发起write()系统调用时,Linux内核会做几项检查,然后直接把数据拷贝到缓冲区中。然后,在后台,内核收集所有这样的“脏”缓冲区(即存储的数据比磁盘上的数据新),进行排序优化,然后把这些缓冲区写到磁盘上(这个过程称为回写writeback)。通过这种方式,write()可以频繁调用并立即返回。这种方式还支持内核把写操作推迟到系统空闲时期,批处理很多写操作。

延迟写并没有改变POSIX语义。举个例子,假设要对一份刚写到缓冲区但还没写到磁盘的数据执行读操作,请求响应时会直接读取缓冲区的数据,而不是读取磁盘上的“陈旧”数据。这种方式进一步提高了效率,因为对于这个读请求,是从内存缓冲区而不是从硬盘中读的。如期望的那样,读写请求相互交织,而结果也和预期一致——当然,前提是在数据写到磁盘之前,系统没有崩溃!虽然应用可能认为写操作已经成功了,在系统崩溃情况下,数据却没有写入到磁盘。

延迟写的另一个问题在于无法强制“顺序写(write ordering)”。虽然应用可能会考虑对写请求进行排序,按特定顺序写入磁盘;而内核主要是出于性能考虑,按照合适的方式对写请求重新排序。只有当系统出现崩溃时,延迟写才会有问题,因为最终所有的缓冲区都会写回,而且文件的最终状态和预期的一致。实际上绝大多数应用并不关心写顺序。数据库是少数几个关心顺序的,它们希望写操作有序,确保数据库不会处于不一致状态。

延迟写的最后一个问题是对某些I/O错误的提示信息不准确。在回写时产生的任何I/O错误,比如物理磁盘驱动出错,都不能报告给发起写请求的进程。实际上,内核内“脏”缓冲区和进程无关。多个进程可能会“弄脏”(即更新)同一片缓冲区中的数据,进程可能在数据仅被写到缓冲区尚未写到磁盘的时候就退出了。进程操作失败,如何“事后”与之通信呢?

对于这些潜在问题,内核试图最小化延迟写带来的风险。为了保证数据按时写入,内核设置了“最大缓存时效”(maximum buffer age),并在超出给定时效前将所有脏缓存的数据写入磁盘。用户可以用过/proc/sys/vm/dirty_expire_centisecs来配置这个值,该值单位是厘秒(0.01秒)。

Linux系统也支持强制文件缓存写回,甚至是将所有的写操作同步。这些主题将在2.4节中详细探讨。

在本章后续部分,2.11节将深入探讨Linux内核缓存回写子系统。