0%

要点

Linux 提供了两种文件加锁系统调用:从 BSD 衍生出来的 flock() 和从 System V 衍生出来的 fcntl()。尽管这两组系统调用在大多数 UNIX 实现上都是可用的,但只有 fcntl() 加锁在 SUSv3 中进行了标准化。

要理解 flock()fcntl() 的锁分别和什么东西有关联,才能理解什么行为会导致锁的意外释放。“锁和什么有关联”意思就是以什么来标识锁的主人。而锁的对象则分别是整个文件(flock())和指定区域(fcntl())。锁是要放在锁的对象上的,所以锁链表在打开文件上记录。

flock() 对整个文件加锁

从 BSD 衍生而来。加的是劝告锁(advisory locking)。

flock(2):

SYNOPSIS
       #include <sys/file.h>

       int flock(int fd, int operation);

2024 年 8 月 5 日:当前 torch 发布的版本是 2.4。PyTorch 的源码中还有几个 YAML 文件,这些文件都挺重要的,可以关注一下。

tools/autograd/derivatives.yaml 中有一些求导代码片段:

- name: prod.dim_int(Tensor self, int dim, bool keepdim=False, *, ScalarType? dtype=None) -> Tensor
  self: prod_backward(grad, self.to(grad.scalar_type()), result, dim, keepdim)
  result: (prod_backward(at::ones({}, result.options()).expand_as(result), self_p.to(result.scalar_type()), result, dim, keepdim) * self_t.conj()).sum(dim, keepdim).conj()

- name: put(Tensor self, Tensor index, Tensor source, bool accumulate=False) -> Tensor
  self: "accumulate ? grad : grad.put(index, zeros_like(source), false)"
  index: non_differentiable
  source: grad.take(index).reshape_as(source)
  result: self_t.put(index, source_t, accumulate)

- name: linalg_qr(Tensor A, str mode='reduced') -> (Tensor Q, Tensor R)
  A: linalg_qr_backward(grad_Q, grad_R, Q, R, mode)
  Q, R: linalg_qr_jvp(A_t, Q, R, mode)

- name: rad2deg(Tensor self) -> Tensor
  self: rad2deg_backward(grad)
  result: auto_element_wise

- name: random_.from(Tensor(a!) self, int from, int? to, *, Generator? generator=None) -> Tensor(a!)
  self: zeros_like(grad)
  result: self_t.zero_()

- name: random_.to(Tensor(a!) self, int to, *, Generator? generator=None) -> Tensor(a!)
  self: zeros_like(grad)
  result: self_t.zero_()

看起来 name 是近似于 python 的伪代码,其他的都是 C++ 代码?

aten/src/ATen/native/native_functions.yaml 这个文件中有 native 函数在不同平台上的派发,比如:

本章介绍在进程的虚拟地址空间上执行操作的各个系统调用。

  1. mprotect() 系统调用修改一块虚拟内存区域上的保护信息。
  2. mlock()mlockall() 系统调用将一块虚拟内存区域锁进物理内存,从而防止它被交换出去。
  3. mincore() 系统调用让一个进程能够确定一块虚拟内存区域中的分页是否驻留在物理内存中。
  4. madvise() 系统调用让一个进程能够将其对虚拟内存区域的使用模式报告给内核。

mprotect()

mmap()prot 参数。

mlock()

可能是为了速度,也可能是为了防止敏感信息写入磁盘(这样恶意程序没办法从磁盘中读取内容)。

一些电脑的休眠模式会在磁盘上存储当前系统运行状态的副本,不管页面有没有被锁定。

SYNOPSIS
       #include <sys/mman.h>

       int mlock(const void addr[.len], size_t len);
       int mlock2(const void addr[.len], size_t len, unsigned int flags);
       int munlock(const void addr[.len], size_t len);

       int mlockall(int flags);
       int munlockall(void);

总体来说,POSIX IPC 的接口比 System V IPC 更简单,而且使用方式也更贴近文件。

  1. POSIX IPC 对象名字需要是类似于 /myobject 的、以斜线开头后面跟非斜线字符的字符串。字符串长度受到 NAME_MAX 限制(255 个字符)。信号量的名字还要少 4 个,因为会增加前缀 sem.。如果不以 / 开头,则是实现定义。IPC 对象名字很像一个根目录下的文件的绝对路径,在有些实现上,IPC 对象真的被放在文件系统上
  2. 创建和打开 IPC 对象很像创建或打开文件。
  3. POSIX IPC 对象有引用计数,进程退出或者关闭 IPC 对象后引用计数就会减少。进程调用 exec() 后 POSIX IPC 对象也会被关闭(很像文件有 close-on-exec 标记){mq,sem,shm}_unlink() 可以用来删除 POSIX IPC 对象,已经打开了 IPC 对象的进程仍能继续使用它们。而 System V 的删除(除了共享内存)是立即生效的,进程的后续访问会出错。
  4. 持久性:和 System V IPC 一样,POSIX IPC 对象也有内核持久性如果不显式删除,将会持续到系统关机)。
  5. IPC 对象管理:System V IPC 可以用 ipcs 来列出 IPC 对象,用 ipcrm 来删除 IPC 对象。POSIX IPC 没有这样标准化的命令。Linux 上的 POSIX IPC 对象被挂载在虚拟文件系统上,其所在目录有粘滞位,可以用 ls(1) 和 rm(1) 这样的标准命令来操作 Linux 的 POSIX IPC 对象。
  6. System V IPC 更古老,可移植性更强。
  7. System V IPC 用 key 来访问,本质都是命名的;POSIX 信号量可以匿名也可以命名;POSIX 用 shm_open 创建共享内存是命名的,但用 mmap 也可以创建匿名共享内存(mmap 也是 POSIX 标准);POSIX 消息队列是命名的。

和 System V 不同点:

消息按照优先级排序

消息按照优先级排序(数值越小越靠前)。每次接收都只能拿到开头的消息,不像 SysV 消息队列那样不按照优先级排序、且可以选择性获取中间的消息。

注册消息通知

POSIX 消息队列还有一个功能就是能够注册消息通知,以得到有消息来临的消息:

  1. 任何时候只能有一个进程可以注册特定队列的消息通知(第二个进程注册时会失败得到 EBUSY 错误)。进程也可以主动解除通知。
  2. 消息通知是一次性的,通知完成之后就会自动解除。
  3. 只有消息队列从空变成非空时,才可能有通知。在非空的状态下来消息是不会产生通知的,只能先清空再来消息才可以产生通知。
  4. 消息队列从空变成非空时,如果有其他因为调用 mq_receive() 而阻塞的进程,那么消息由其中的一个进程获取,而不会产生通知!!

API:

内存映射分类

内存映射可以通过 mmap() 函数来完成。

  1. 内存映射从可见性来讲可以分成私有(MAP_PRIVATE)和共享(MAP_SHARED)。
    1. 私有内存映射有写时复制语义。
    2. 共享映射页面上的内容变化对所有共享者都可见。共享文件映射的页面变化还会同步回文件。
  2. 内存映射从映射类型上可以分成文件映射和匿名映射。
    1. 文件映射页面被初始化为文件对应位置的内容。
    2. 匿名映射页面被初始化为 0。

因此,它们可以有以下 4 种组合方式:

  1. 私有文件映射:映射的内容会被初始化为相同的内容。多个映射同一个文件的进程一开始会共享内存的物理分页,但是在修改时会触发写时复制。在私有文件映射上面的变更不会同步回文件。
  2. 私有匿名映射:malloc() 申请大块内存时就会使用 mmap() 的私有匿名映射。这样的映射虽然会在 fork() 后由子进程继承,但是在写入页面的时候会触发写时复制。
  3. 共享文件映射:主要功能有两个,即文件映射 I/O 和无关进程的 IPC。
  4. 共享匿名映射:可以通过 fork() 由相关进程共享。

Note

进程的文本段就是私有文件映射。尽管文本段保护位一般是 PROT_EXEC | PROT_READ,而且程序本身也一般不会尝试去修改代码本身,但是调试器等可能会修改程序代码。我们不希望这样的修改操作同步回可执行文件,所以使用 MAP_PRIVATE 而不是 MAP_SHARED 来映射文本段。

进程的初始化数据段也是私有文件映射。(书上没说,但是未初始化数据段大概是私有匿名映射吧?)

共享内存是 IPC 机制中最快的一种,因为共享内存段会成为进程用户空间内存的一部分,因此在申请完成之后无需内核介入便可通信。作为对比,管道数据生产者需要将数据从用户缓冲区(如果使用 stdio)写入到内核缓冲区,管道数据的消费者也需要从内核缓冲区读取信息。

使用方式:

  • shmget() 创建或取得共享内存段的标识符。
  • shmat() 来附上共享内存段。
  • shmdt() 来分离共享内存段,此操作完成之后共享内存段将无法访问。在进程终止时,未分离的共享内存段会自动分离。
  • shmctl() 来删除共享内存段。只有所有附加到共享内存段的进程都与其分离后,内存段才会被销毁

创建 System V 共享内存段

#include <sys/shm.h>

int shmget(key_t key, size_t size, int shmflg);

标志 IPC_CREAT 表示如果不存在和 key 对应的段,就创建一个新的;IPC_EXCL 表示共享内存段必须由当前进程创建,否则返回错误并设置 errno 为 EEXIST,使用时要同时指定 IPC_CREAT(man 手册中没讲不同时指定会怎么样)。其他标志略。

SysV 信号量是以信号量集的形式出现的。

创建 SysV 信号量后需要显式初始化

在 Linux 中,创建的 SysV 信号量的 semval 会被初始化为 0,但是这个行为是不可以移植的。在其他系统中,需要手动创建,再跟着初始化,这两部分可能会出现同步错误。

使用 semctl 前需要自己提供 semun 定义

参考 man semctl,应用程序必须自己提供 semun 的定义(如果想要给 semctl 传递第 4 个参数)。这个定义并不在头文件里!

union semun {
   int              val;    /* Value for SETVAL */
   struct semid_ds *buf;    /* Buffer for IPC_STAT, IPC_SET */
   unsigned short  *array;  /* Array for GETALL, SETALL */
   struct seminfo  *__buf;  /* Buffer for IPC_INFO
                               (Linux-specific) */
};

semop() 和操作系统课程所学有什么差异?

共性:永不为负,P 和 V 都是原子操作。关于“永不为负”这一点我还去求证了一下,发现确实是自己本科学的东西记错了。

System V IPC 对象 xxx_get 来打开(含创建)、xxx_ctl 来控制(含删除)。

操作 System V IPC 对象需要 key,key 可以用 IPC_PRIVATE 来让系统创建一个独一无二的,也可以从 ftok() 按照文件的 inode 号生成。System V IPC 其实都是命名的,IPC_PRIVATE 并不能真正实现匿名,只是生成一个不重复的名字罢了

System V IPC 对象具有内核持久性,消息队列和信号量是无连接的(删除立即生效)、共享内存段有引用计数(比较像文件)。

一些命令:

  • ipcs:查看当前 System V IPC 对象的使用情况。
  • ipcs -l:列出 System V IPC 对象的资源上限。
  • ipcrm -[M|Q|S] key / ipcrm -[m|q|s] id:删除 System V IPC 对象。

管道

管道:一般指的是匿名管道

创建方式:

  1. 可以用 pipe() 创建,通过 fork() 或者 UNIX 域套接字共享给其他进程。
  2. 也可以通过 popen() 创建子进程。

popen()system() 有一些差异:

  1. system() 会为调用进程忽略 SIGINT 和 SIGTERM,但是 popen() 不会忽略这些信号,因为调用进程没有阻塞等待子进程。
  2. popen() 不会阻塞 SIGCHLD。如果阻塞了,那么在对应的 pclose() 之前就不能正常接受子进程退出的消息了。但这也有个问题:wait() 可能会接收到 popen() 创建的子进程的消息,这样调用 pclose() 的时候就会返回 -1 并设置 errno 为 ECHLD。
  3. popen()pclose() 配套。除了关闭文件描述符之外,pclose() 还会回收子进程,所以不能用 fclose() 代替 pclose()