0%

2025/3/8 个人理解:

  • socket:UNIX 域套接字还是网络套接字,这取决于通信进程是否属于同一个主机。
  • 管道:包括匿名管道(pipe 系统调用)和命名管道(fifo 文件)。
  • System V IPC 三大件(POSIX 中也能找到这几个组件的替代物)
    • 信号量
    • 共享内存
    • 消息队列
    • System V IPC 用 key 来访问,本质是命名的,IPC_PRIVATE 只是生成独一无二的名字而已;除了消息队列必须命名之外,POSIX 中其他两个既可以命名也可以匿名。
    • 其他区别见 51 POSIX IPC 介绍
  • mmap:单独用 mmap 可以实现匿名共享内存,但不能解决命名共享内存的问题。POSIX 共享内存可以解决命名共享内存的问题,但是要配合 mmap 才能附加到进程地址空间。mmap 因为很特殊所以单独放出来,没合并到共享内存里面去。
  • 文件锁
  • futex
  • 信号:信息传输效率低,一般用于操作系统给进程通知信息,或者用户交互式干预进程运行。

基本认识

伪终端解决这样的问题:远程登录等场合,用户并不能和真正的终端进行交互,而且信息也不能简单通过 socket 转发,因为很多(面向终端的)应用程序是假设了有控制终端的。应用了伪终端的程序包括:script(1)(能录制本次交互程序的全部用户输入和用户能看到的输出)、screen(1)、expect(1)(在 一个可以用来测试某交互程序的是否如期运行的工具,在 Debian 上需要额外安装)、xterm 等终端模拟器

伪终端很像一个双向管道。主设备写入、从设备可以读;从设备写入、主设备可以读。在从设备侧,伪终端表现得就和真实终端一样:所有在终端上能使用的系统调用在伪终端上都不会报错,对伪终端无意义的设置也会被忽略。因此,驱动程序(接收用户输入)面向主设备,应用程序面向从设备(它们对自己使用的是真实终端还是伪终端可能并不知情)。

System V (UNIX 98) 伪终端

  • posix_openpt() 函数可以打开一个未使用的伪终端主设备,返回其 fd。
  • grantpt() 可用于修改对应的从设备的属组和权限。
  • unlockpt() 用于解锁从设备,这样从设备就能被打开了(这是为了应对竟态条件)。
  • ptsname() 函数返回主设备对应的从设备名称。

posix_openpt()O_NOCTTY 标志可以使得伪终端主设备不成为当前进程的控制终端,在 Linux 上是默认启用的;O_RDWR 标志以读写方式打开,一般开发者都会指定此标志。最初 System V 终端实现中,获取主设备是通过打开伪终端主克隆设备 /dev/ptmx 实现的。也就是说,posix_openpt() 完全可以被实现为:

int
posix_openpt(int flags)
{
    return open("/dev/ptmx", flags);
}

实验设计

以下实验都是在 wsl 中进行的。

$ cat /proc/sys/fs/file-max   # 整个系统中能使用的最大文件描述数量
9223372036854775807
$ cat /proc/sys/fs/nr_open    # 单个进程中能使用的最大文件描述符个数
1048576

编译运行以下测试代码(不要用 Release 模式、不要增加 -DNDEBUG;这份代码还假设了 0、1、2 号文件描述符已经被打开):

#include <cassert>
#include <cerrno>
#include <cstdio>
#include <cstdlib>
#include <fcntl.h>
#include <sys/resource.h>
#include <unordered_set>
#include <vector>

int main() {
  // check rlimit
  int ret;
  struct rlimit rlimit_res;
  if ((ret = getrlimit(RLIMIT_NOFILE, &rlimit_res)) == -1) {
    perror("getrlimit");
    exit(EXIT_FAILURE);
  }
  printf("soft limit of fd count: %lu\n", (unsigned long)rlimit_res.rlim_cur);
  printf("hard limit of fd count: %lu\n", (unsigned long)rlimit_res.rlim_max);

  // try to open as many files as we can
  std::vector<int> opened_files{0, 1, 2};
  while (true) {
    int fd = open("/dev/zero", O_RDONLY);
    if (fd == -1) {
      if (errno == EMFILE) {
        break;
      } else {
        perror("open");
        exit(EXIT_FAILURE);
      }
    }
    opened_files.push_back(fd);
  }
  // check if sorted
  for (size_t i = 1; i < opened_files.size(); ++i) {
    assert(opened_files[i - 1] < opened_files[i]);
  }
  // find missing fds
  {
    int maxfd = opened_files.back();
    std::unordered_set<int> st(opened_files.begin(), opened_files.end());
    for (int i = 0; i < maxfd; ++i) {
      if (!st.count(i)) {
        printf("missing fd %d\n", i);
      }
    }
  }
  printf("fd count: %zd\n", opened_files.size());
}

实验结果

直接通过 shell 进入 WSL:Debian 时,运行结果如下:

整体概览

终端可以分成两种工作模式:1. 规范模式(输入按照行来处理);2. 非规范模式,比如 vim、less 等程序中。

终端驱动程序的作用是:操作两个队列,一个用于从终端设备把输入字符传送到读取进程上,另外一个用于将输出字符从进程传输到终端上。如果开启了终端回显功能,那么终端输入队列上面的新字符也会自动被追加到输出队列的尾部。终端驱动程序还能识别终端输入中的特殊字符,并根据字符含义做出相应的行为(比如 ctrl + d 和 ctrl + c)。

如果进程想要知道终端上面有多少没有读取的内容(假设终端的是其标准输入),在 Linux 上面可以使用 ioctl(fd, FIONREAD, &cnt)。这个特性在 SUS 中没有规定。

获取和修改终端属性

可以用 tcgetattr(2) 和 tcsetattr(2) 操作终端属性。这只能由前台进程调用,如果是后台进程,会因为没有控制终端而收到终端驱动程序发来的 SIGTTOU 信号。

概览

  • I/O 多路复用,select()poll(),检查大量文件描述符时性能不好
  • 信号驱动 I/O
  • Linux 特有的 epoll()
  • POSIX 异步 I/O(AIO),在本书不讲(应该也不是一种通知模型)

Tip

Libevent 库为包括 select()poll()epoll()、信号驱动 I/O、Solaris 专有的 /dev/poll 和 BSD 专有的 kqueue 接口在内的很多 I/O 方式提供了抽象层。

通知文件描述符就绪的两种方式是水平触发和边缘触发。

  • 水平触发:文件描述符上可以非阻塞执行 I/O 调用,则认为已经就绪。select() / poll()epoll() 可以支持水平触发模型。
  • 边缘触发:文件描述符自上次检查以来有了新的 I/O 活动,则认为需要通知。信号驱动 I/O 和 epoll() 可以支持水平触发模型。

书上还提到边缘触发时一般需要尽可能读取完所有字节,以免很长时间没有接收到下一次通知。比如:在循环中每次检查是否有数据可以读,如果有,则最多读取 1024 个字节,然后进入下一个循环,这样的操作就只能在水平触发模型上正常工作。这是因为只要状态没有改变,边缘触发模式下的通知系统就不会再次发送通知,如果只最多读取 1024 个字节,后面即便有数据没有读取完成,也不会有新的通知了。

部分读和部分写

套接字上面可能发生部分读和部分写,书中提供了 writen() 和 readn() 函数来保证读写完数据,接口和 write() / read() 一样,可以借鉴一下这种思路。

shutdown() 函数

可以指定关闭 socket 的读 / 写 / 读写。如果不用 shutdown(),打开的 socket(文件描述)只会在所有指向它的文件描述符都关闭了之后才会被关闭。

  • SHUT_RD:关闭读端。在 UNIX 域流套接字上面执行了这个操作之后,对端应用程序写入会受到 SIGPIPE 信号(或者 EPIPE 错误,如果屏蔽或处理了这个信号)。对于 TCP 套接字,关闭读端没有意义。
  • SHUT_WR:关闭写端。读端能继续读,读完之后能看到 EOF,写端不能再写(SIGPIPE 和 EPIPE)。
  • SHUT_RDWR:相当于先执行 SHUT_RD 再执行 SHUT_WR

recv()send()

接口相比 read()write() 多了一个 flags 参数,能在 socket 操作上面对本次读写实现更多的功能控制。

sendfile()

sendfile() 能将文件(fd)的指定范围(offset 和 count)的数据直接发送到套接字中。与先读数据后写入相比,这样的操作不需要经过用户缓冲区,能直接在内核缓冲区完成传输。

迭代型服务器和并发型服务器

迭代型服务器每次只处理一个客户端,在处理客户端请求时无法响应其他客户端;并发型服务器每次接受新的请求后,就会创建新的线程或者进程去专门处理这个请求。

inetd(Internet 超级服务器)守护进程

/etc/services 下列举着大量服务,但是每个服务器只是偶尔运行。inetd 进程通过监听一组 socket 端口并按需启动对应的服务来节省系统开销。因此 inetd 又被叫做 Internet 超级服务器。守护进程 inetd 的行为由配置文件 /etc/inetd.conf 控制(在我的 WSL(Debian)上面没有安装 inetutils-inetd,所以没有这个配置文件)。修改配置文件之后,向 inetd 发送 SIGHUP 可以要求其重载配置。

许多发行版有更加先进的 xinetd(8)。

几列数据分别是服务的名称(可以根据 /etc/services 查到服务对应的端口,理论上有些服务在不同协议下有可能使用不同端口,但是我看 /etc/services 中的服务都是 udp 和 tcp 用相同的端口号)、socket 的类型、协议、标记(要么是 wait,要么是 nowait;wait 表示在子进程运行时暂时将对应 socket 从监听列表中移除,等子进程退出后再次加入监听)、登录名(控制子进程的用户名和组名,由于 inetd 是 root 运行的,所以不控制子进程的凭据会有很大的风险)、程序位置、参数列表(图中只提供了 aegv[0])。

多宿主机(Multihomed host)指拥有多个网络接口的主机(可以是个人计算机,也可以是路由器)。

在网络层中,IPv4 为 IP 头提供了校验和,这样就能检测出头中的错误,但是 IPv4 并没有为包的内容提供校验。IPv6 则没有为 IP 头提供校验和。在传输层中,UDP 校验和在 IPv4 上可选、在 IPv6 上强制;TCP 校验和总是强制的。

常量 INADDR_ANY 就是所谓的 IPv4 通配地址,大多数实现将其定义成了 0.0.0.0。使用通配地址,多宿主机可以接收任意一个主机 IP 地址的数据。

IPv6 的环回地址为 ::1,通配地址为 0::0 或者 ::(同一个值的两种写法而已)。IPv6 还提供了“IPv4 映射的 IPv6 地址”,这样的 IPv6 地址是在 IPv4 地址前面加上 ffff(两个字节的全 1),然后再加上一堆 0 形成的。与 204.152.189.116 等 价 的 IPv4 映 射 的 IPv6 地 址 是 ::FFFF:204.152.189.116。

Socket 基础

Socket 分为 UNIX Domain Socket 和 Internet Domain Socket,两者都有数据报和字节流两种工作模式。前者(UNIX Domain Socket)是同一台主机不同进程之间通信的方式,是可靠的通信(包括数据报通信方式)。

SUS 规定 socket 至少可选以下的 domain,具体实现可以提供更多选择:(AF 表示地址族,PF 表示协议族,早期设计人员认为一个协议族可以支持多个地址族,但实际上协议族和地址族基本上是一一对应的关系,而且所有 PF_ 开头的常量都被定义成对应的 AF_ 开头的常量。)

SUS 规定 socket 至少可选流(SOCK_STREAM)和数据报(SOCK_DGRAM),具体实现可以提供更多选择,比如 SOCK_RAW 表示直接使用 IP 而不是传输层协议,但这可能需要特权。在流类型的 socket 中,连接的另外一方被称为 peer socket(对等 socket)。

流 socket

UNIX 域套接字 bind() 时会在文件系统上面创建文件

UNIX domain socket 在 bind 的时候会在文件系统上面创建一个 socket 文件。

如果文件系统上面已经有该路径,则会绑定失败。往往是服务器在 bind() 之前调用 remove() 来尝试删除旧文件(remove() 既可以删除文件夹又可以删除普通文件),在不再需要 socket 时也会立即调用 unlink() 来删除 socket,等所有使用该 socket 的进程退出之后文件就会被文件系统自动清理。

Linux 特有的抽象 socket 名字空间

bind() 的时候把 struct sockaddr_unsun_path 字段的第一个字节指定为 '\0',后面仍然填 socket 名字,则可以为 socket 创建一个抽象名字,这样 socket 就不会出现在文件系统上面,也不会和文件系统上面的条目冲突。

UNIX domain 数据报 socket 能传输的最大大小

通过 SO_SNDBUF 选项和 /proc 文件系统下面的限制表示。可以参考 socket(7)。我本地看是 212992。

为 UNIX domain socket 创建 socket 对

socketpair() 系统调用有一点像 pipe() 系统调用,前者会创建匿名的 socket 对,可以跨过 fork() 共享给子进程。这个系统调用为我们省去了 socket()bind()listen()connect()accept() 等环节,而且创建的 socket 对外是不可见的。