linux 系统调用 write 的原子性

2022-06-27 12:32:49 浏览数 (1)

1. 问题描述

开始阅读 nginx 源码的时候就一直伴随着一个问题,那就是多进程的 nginx 模型是怎么保证多个进程同时写入一个文件不发生数据交错呢? 猜想中,主要有以下几种解决方案: 1. 最传统的,正在写文件的进程加锁,其他进程等待,但是这样的情况是绝对不允许的,效率太过低下 2. 写 log 前测试锁状态,如果已经锁定,则写入进程自己的缓冲区中,等待下次调用时同步缓冲区,这样做的好处是无需阻塞,提高了效率,但是就无法做到 log 的实时了,这样做工程中也是绝对无法接受的,一旦发生问题,将无法保证 log 是否已经被写入,因此很难定位 3. 一个进程专门负责写 log,其他进程通过域套接字或者管道将 log 内容发送给他,他持续阻塞在 epoll_wait 上,直到收到信息,立即写入,但是众所周知,nginx 是调用同一个函数启动所有进程的,并没有专门调用函数启动所谓的 log 进程,除了 master 和 worker,nginx 也确实没有 log 进程存在 4. 那么就是进程启动后,全部去竞争某个锁,竞争到该锁的 worker 执行 log worker 的代码,其余的 worker 继续运行相应程序,这个方案看上去是一个不错的方案,如果是单 worker 的话,那么就无需去使用该锁即可

利用周末的空闲时间,终于进行了一番探究,究竟 nginx 使用的是上述方案中的哪一个呢?还是另有妙方?

2. nginx 具体实现

通过阅读源码,我们发现 nginx 只有一把互斥锁,即用来避免惊群现象的 ngx_accept_mutex 锁,其余地方完全没有用到锁机制,这么做原因很简单,在工程化的代码中,盲目使用锁会造成性能的下降,这是不可以接受的。 进一步阅读源码,发现,ngx_log_error 这个函数中调用了 ngx_log_error_core 函数,这个函数正是用来打印错误日志的:

代码语言:javascript复制
// void ngx_cdecl ngx_log_error(ngx_uint_t level, ngx_log_t *log, ngx_err_t err,
//     const char *fmt, ...)
// 打印错误日志 {{{
void ngx_cdecl
ngx_log_error(ngx_uint_t level, ngx_log_t *log, ngx_err_t err,
    const char *fmt, ...)
{
    va_list  args;

    if (log->log_level >= level) {
        va_start(args, fmt);
        ngx_log_error_core(level, log, err, fmt, args);
        va_end(args);
    }
} // }}}

接下来,他调用了 ngx_log_error_core 这个函数,在这个函数中,将日志信息进行格式化后调用:

代码语言:javascript复制
(void) ngx_write_fd(log->file->fd, errstr, p - errstr);

写入日志,而 ngx_write_fd 这个调用却是:

代码语言:javascript复制
static ngx_inline ssize_t
ngx_write_fd(ngx_fd_t fd, void *buf, size_t n)
{
    return write(fd, buf, n);
}

那么,这么调用真的不用担心多进程写 log 时数据交错的发生吗?

3. SUS 标准

在 APUE (《UNIX 环境高级编程》) 中有这么一段话: 如果多个进程都需要将数据添加到某一文件,那么为了保证定位和写数据这两步是一个原子操作,需要在打开文件时设置O_APPEND标志。

那这么说,一但开启 O_APPEND 标志,write 就是一个原子操作了吗? Single UNIX Specification 标准对此进行了详细的说明,内核在调用 write 前会对文件进行加锁,在调用 write 后会对文件进行解锁,这样保证了文件写入的原子性,也就无需担心数据交错的发生了。

那么对于不同类型的文件与不同的系统实现 write 究竟是怎么处理的呢?

3.1. 普通文件

有三种情况可能导致文件写入失败: 1. 磁盘已满 2. 写入文件大小超出系统限制 3. 内核高速缓存区已满

遇到这三种情况怎么处理呢? 如果是使用 O_NONBLOCK 标识打开文件的话,write 会立即返回,返回值小于写入字符数这个参数,虽然写入了不完整数据,但是内核保证其写入过程的原子性,否则内核会让调用进程睡眠,直到文件重新可写,这样内核保证了写入数据的完整性,但是不保证写入的原子性。 也就是说,如果在打开文件时设置了 O_NONBLOCK 标识(或打开文件后用 fctnl 函数设置),则虽然可能写入部分数据,但是写入过程是原子性的。 linux 系统默认使用 O_NONBLOCK 标识打开文件,而 bsd 等 unix 系统则恰恰相反。

3.2. 管道

SUS 标准对管道写入有着明确的说明,只要一次性写入数据小于管道缓冲区长度(PIPE_BUF),那么不论 O_NONBLOCK 标识是否开启,管道写入都是原子性的,多个进程同时写入同一管道是一定不会出现数据交错的,否则,依然可能出现数据交错。

3.3. socket

linux 2.6.14 内核对 tcp socket 写操作进行了说明,他并不是原子的。 也许操作系统设计者认为,socket 是有可能永久阻塞的,所以如果保证这样的 IO 具备原子性是十分荒唐的一件事吧。 因此,对于 UNIX 日志系统服务器的操作,必须每个线程都单独进行一次 connect,保证每个线程使用不同的 fd 进行写入,这样才能防止数据交错的发生。 当然了,对于 udp socket 则无需担心这一问题。

4. 原子性的可靠性

那么问题来了,nginx 直接调用 write,这样靠谱吗? 经过上面的介绍,对于写入普通文件的情况,只要文件是使用 O_NONBLOCK 标识打开的,那么就可以保证其写入的原子性,也就是说这样写入是可以接受的。 但是这并不意味着这样做是靠谱的,这样做依然可能无法成功写入全部数据。 然而,nginx 并没有对返回结果进行判断,他并不关心是否写入成功,这显然是不严谨的,但是作为一个工程化项目,这是不得不进行的妥协。

那么,你也许会问,write 保证原子性难道不是靠加锁实现的吗?为什么我不可以在我的进程中加锁实现更加可靠的 write 呢? 虽然上文已经介绍,这里还是单独强调一下。 在用户进程中使用互斥锁加锁,内核首先需要从用户态陷入内核态,调用系统调用,操作堆栈,然后进行文件操作,然后清理堆栈,再从内核态回到用户态,这个过程是很慢的,而对于用户实现的互斥锁,在这个过程中,其他进程是无法进行文件操作的,无论是缓存到进程所使用的内存中,还是阻塞还是丢弃都不是很好的解决方法。 而对于操作系统来说,内核对文件加锁是在系统调用内实现的,也就是已经陷入内核态实现,这个过程只需几个汇编指令即可,也无需对堆栈进行操作:

代码语言:javascript复制
mutex_lock:
    TSL REGISTER, MUTEX            '将互斥量复制到寄存器并将内存中互斥量置为 1
    CMP REGISTER, #0            '测试寄存器内容是否为 0
    JZE ok                        '未锁定状态,执行相应操作
    CALL thread_yeld            '锁定状态,调度另一线程
    JMP mutex_lock                '稍后重新检测锁是否可用
ok: CALL write_opt                '执行具体操作

...

mutex_unlock:
    MOVE MUTEX, #0                '将内存中互斥量置为 0
    RET                            '返回调用者

0 人点赞