🐧 第二章 用户模式实现的功能
📄 一句话概括

裸系统调用出错时给你 -1 加一个 errno;包装函数把它翻成异常、顺手检查参数、补齐平台差异。代价只是几个百分点,换来的是错误不会被静默忽略。

📝 本节和前面几节的关系

2.1 讲门本身,这一节讲门边那个【传达室】——你写的 open() 其实不是系统调用,是包装函数;真正的系统调用在它后面一层。

一、裸系统调用长什么样

直接从 C 里调系统调用,你会看到这种画风: 返回 -1 表示失败,具体什么错放在全局变量 errno 里。成功时返回文件描述符(一个非负整数)。 问题在于:-1 是一个合法的值域外的数,但它不会自己喊。你不检查,程序就带着一个无效的 fd 往下跑,直到某处炸掉——而炸掉的地方跟真正的错因隔着十万八千里。 更麻烦的是 errno 是个全局变量:一个线程改了,另一个线程读到的是别人的错。所以现代实现把它做成每线程一份。

二、包装函数干了三件事

第一件,翻错误。把 -1 + errno 翻成异常或错误码,让调用方必须处理——语言帮你把【忘记检查】这条路堵死。 第二件,检查参数。路径是不是空、flags 组合有没有矛盾、缓冲区大小是不是超了。这些检查在用户态做比进内核做便宜得多。 第三件,补平台差异。同一个 open,不同操作系统、不同架构的系统调用号、参数顺序、甚至语义都不一样。包装函数把这些抹平,你的代码才可移植。

还有第四件,容易被忽略:重试。某些系统调用会被信号打断,返回 EINTR——包装函数会悄悄替你重来一次,你的代码里看不见这件事。

三、Python 的 os 模块就是那层包装

os.open 是最贴近裸系统调用的一个:参数就是 flags 位运算,没有缓冲、没有文件对象。失败时它抛 OSError,异常里带着 errno。 而 open() 更靠上一层:它返回文件对象,自带缓冲、自带编码转换、自带 with 关闭。 所以同一件事你在 Python 里有三档可选: 1. open()——最舒服,缓冲与编码都替你管好了。 2. os.open + os.read——贴地飞行,能精确控制每一次过门。 3. mmap——干脆把文件映射进地址空间,读写变成访存。 三档不是新旧关系,是三条不同的路,代价不同。

四、代价与收益的账

下面 Demo 把两档摆在一起量:直接 os.open 与走 open() 对象,同一件事各花多少。 结果会看到包装那一层确实更贵——对象要造、缓冲要建、异常路径要准备。 但代价是常数级的(几倍以内),收益是结构性的: 1. 错误不会被漏掉(异常强迫你处理)。 2. 代码可移植(平台差异被抹平)。 3. 资源不会漏关(with 保证释放)。 所以判据是:默认走包装层,只有在【每秒几百万次】这种量级上才考虑往下钻。

五、动手 Demo:包装函数替你做了什么——从 -1 与 errno 到异常

同一件事写两遍:一遍直接调 os.open(拿返回值和 errno),一遍走 open()(拿异常)。然后看两种交法各付出多少。

def raw_open(path):
    try:
        return os.open(path, os.O_RDONLY), 0
    except OSError as e:
        return -1, e.errno      # 裸风格:返回 -1 + 错误码

def wrapped_open(path):
    return open(path, "rb")       # 包装风格:直接抛异常

# 同一个失败,两种交法
fd, err = raw_open("_d29_no_such_file.txt")
print(fd, err, os.strerror(err))   # -1 2 No such file...

实跑输出:

--------------------------------------------------------------------
包装函数替你做了什么——从 -1 与 errno 到异常
--------------------------------------------------------------------
① 同一个失败,两种交法

  风格           返回什么               调用方要做什么
  ----------------------------------------------------------
  裸系统调用     fd = -1,errno = 2     自己查 errno 再判断

      裸风格里 errno = 2,含义:No such file or directory

      包装风格抛了                FileNotFoundError,errno = 2

  **两种交法的信息量完全一样,差别在【谁来检查】。**
  **裸风格把检查推给每个调用方——漏一个就是一个静默的错。**

② 一次多余的门:同样的活儿,包一层要多花多少

  写法               每次耗时       多出来的是什么
  -------------------------------------------------------
  直接调系统调用     106044 纳秒    ——
  走包装函数         86988 纳秒     对象、缓冲、异常翻译

  **包装函数不是白给的便宜,它有成本,只是成本远小于它省下的事。**
  **它替你办三件:把 -1 翻成异常、把缓冲管好、把参数检查掉。**

两种交法的信息量完全一样:都是【没找到】。差别只在谁来检查。 裸风格把检查推给每一个调用方——漏一个,就是一个静默的错,而错会一直传到很远的地方才炸。 包装风格把检查变成强制的:不处理异常,程序就停在那儿——停在你写错的地方,而不是三小时后在别处。 第二张表是价钱:包装层更贵,但贵在常数上。它买的不是速度,是【错误不会被忽略】。

📋 在真 Linux 上对照:man 2 open 是系统调用(返回值 -1 / errno),man 3 fopen 是标准库(返回 NULL / 返回错误码),两个手册页码不同,就在告诉你它们不是一层东西。

30 秒实验

  1. 在 Python 里试 os.open("/no/such", os.O_RDONLY),捕获异常并打印 e.errno 与 e.strerror——异常里原样带着 errno。
  2. 试 open("/etc/hostname", "r") 与 os.open("/etc/hostname", os.O_RDONLY),分别 type 一下返回值:一个是文件对象,一个是整数。
  3. python -c "import os; os.close(999)",看包装层怎么把 EBADF 翻成异常。

常见误区

  1. 【包装函数是多余的一层,去掉更快】。快几个百分点,换来的是错误被静默吞掉的概率。只有在每秒百万级的循环里才值得往下钻。
  2. 【errno 是每个进程一份】。现代实现是每线程一份(__thread int errno);进程级别的话,多线程程序里读到别人的错是常态。
  3. 【异常比返回值慢,所以异常是不好的设计】。异常只在出错路径上贵;正常路径几乎不花钱。拿错误处理的开销去评价异常,是拿错了尺子。
自测三问(点开对答案) 1. 裸系统调用怎么报错?它最大的隐患是什么? —— 返回 -1,错误码放进 errno。隐患是**不会自己喊**——忘了检查就带着无效值往下跑,炸的地方离错因很远。 2. 包装函数干的三件事(外加一件容易忽略的)是什么? —— 翻错误、检查参数、补平台差异;容易忽略的是**被信号打断时自动重试**(EINTR)。 3. 什么时候该绕过包装层直接用系统调用? —— 只在每次过门的成本都重要的场合——比如每秒百万次的小读写、需要精确控制 mmap 与缓冲的时候。**常规业务代码不该绕过。**

相关笔记