Linux 管道的速度到底有多快?

在本篇文章中,我们将通过逐步优化一个通过管道进行数据读写的测试程序,来探索 Unix 管道在 Linux 中的实现方式。1

我们将从一个吞吐量约为 3.5GiB/s 的简单程序开始,并将其性能提升二十倍。这些改进将基于使用 Linux 的 perf 工具) 对程序进行性能分析。2 相关代码 已发布在 GitHub 上

图表展示我们的管道测试程序性能。

这篇文章的灵感来自于阅读一个高度优化的FizzBuzz程序,该程序在我的笔记本电脑上以约35GiB/s的速度将输出推送到管道。3 我们的第一个目标是达到该速度,并在过程中详细解释每个步骤。我们还将添加一项额外的性能优化措施,该措施在FizzBuzz中无需使用,因为瓶颈实际上是计算输出而非I/O,至少在我的机器上是如此。

我们将按照以下步骤进行:

  1. 管道测试平台的第一个慢速版本;
  2. 管道的内部实现方式,以及为何从管道中读写数据会很慢;
  3. vmsplicesplice 系统调用如何帮助我们绕过部分(但不是全部!)的性能瓶颈;
  4. Linux 页面管理机制的描述,以及如何通过使用巨大页面实现更快的版本;
  5. 最终优化:用忙循环替换轮询;
  6. 一些结尾思考。

第4节涉及最多的Linux内核内部实现细节,因此即使你对本文中其他主题已经熟悉,这部分内容也可能值得一读。对于不熟悉本文主题的读者,仅假设具备C语言的基本知识。

让我们开始吧!

  1. 这将与我的atan2f性能调查采用类似的风格,尽管本文讨论的程序仅用于学习目的。此外,我们将以不同的层次进行代码优化。调整 atan2f 时主要通过汇编输出进行微优化,而调整管道程序则需要分析 perf 事件并减少各种类型的内核开销。↩︎
  2. 测试在 Intel Skylake i7-8550U CPU 和 Linux 5.17 系统上运行。您的实际结果可能会有所不同,因为本文中描述的程序所依赖的 Linux 内核在过去几年中一直在不断变化,未来版本中也可能会继续进行调整。继续阅读以获取更多细节!↩︎
  3. “FizzBuzz”据称是常见的编程面试题。具体细节与本文无关,但可在链接中查阅。我个人从未被问及此题,但有可靠消息称此类情况确实存在!↩︎

挑战与初始版本的缓慢实现 #

元素周期表首先,让我们从测量传说中的FizzBuzz程序的性能开始,遵循StackOverflow帖子中规定的规则:

% ./fizzbuzz | pv >/dev/null
 422GiB 0:00:16 [36.2GiB/s]

pv 是“管道查看器”,一个方便的工具 用于测量通过管道传输的数据吞吐量。因此,fizzbuzz 的输出速率为 36GiB/s。

fizzbuzz 以与 L2 缓存大小相同的块大小写入输出,以在内存访问成本和最小化 I/O 开销之间取得良好平衡。

在我的机器上,L2 缓存大小为 256KiB。在本篇博文中,我们也将输出 256KiB 的块,但不进行任何“计算”。本质上,我们将尝试测量具有合理缓冲区大小的管道写入程序的上界。4

虽然 fizzbuzz 使用 pv 测量速度,但我们的设置略有不同:我们将分别在管道的两端实现程序。这样可以完全控制与从管道推送和拉取数据相关的代码。

  1. 尽管我们固定了缓冲区大小,但如果使用不同的缓冲区大小,实际数值差异并不显著,因为其他瓶颈会开始影响性能。↩︎

代码可在我的 pipes-speed-test 仓库中获取[https://github.com/bitonic/pipes-speed-test]。`write.cpp` 实现写入功能,read.cpp 实现读取功能。write 程序会无限循环写入相同的 256KiB 数据。read 程序读取 10GiB 数据后终止,并打印吞吐量(单位为 GiB/s)。这两个可执行文件均支持多种命令行选项以调整其行为。

首次尝试从管道进行读写操作将使用writeread系统调用,使用与fizzbuzz相同的缓冲区大小。以下是写入端的部分代码:

int main() {
  size_t buf_size = 1 << 18; // 256KiB
  char* buf = (char*) malloc(buf_size);
  memset((void*)buf, ‘X’, buf_size); // 输出 X
  while (true) {
    size_t remaining = buf_size;
    while (remaining > 0) {
      // 继续调用 `write` 直到缓冲区全部写入
      // 目标位置。请记住,write 函数返回的是
      // 它能够写入目标位置的字节数——在本例中,
      // 目标位置是管道。
      ssize_t written = write(
        STDOUT_FILENO, buf + (buf_size - remaining), remaining
      );
      remaining -= written;
    }
  }
}

此代码片段及后续片段为简洁起见省略了所有错误检查。5 memset 确保输出内容可打印,但它还承担另一个作用,我们稍后会详细讨论。

所有工作均由 write 调用完成,其余部分仅确保整个缓冲区被完整写入。读取端与之类似,但通过read将数据读入buf,并在读取足够数据后终止。

构建完成后,可按以下方式运行仓库中的代码:

  1. 如有需要,可参考仓库获取详细实现。更一般地说,我不会在这里逐字复制代码,因为细节并不重要。我将发布一些代表正在发生情况的代码片段。↩︎
% ./write | ./read
3.7GiB/s, 256KiB 缓冲区, 40960 次迭代 (10GiB 管道传输)

我们正在将相同的 256KiB 缓冲区(填充为 ‘X’)写入管道 40960 次,并测量吞吐量。令人担忧的是,我们的速度比 fizzbuzz 慢了 10 倍!而我们实际上没有做任何工作,只是将字节写入管道。

事实证明,使用 writeread 无法再显著提升速度。

write 的问题 #

要找出程序在哪些地方花费了时间,我们可以使用 perf):6 7

% perf record -g sh -c ‘./write | ./read3.2GiB/s, 256KiB buffer, 40960 iterations (10GiB piped)
[ perf record: Woken up 6 times to write data ]
[ perf record: Captured and wrote 2.851 MB perf.data (21201 samples) ]

-g 选项指示 perf 记录调用图:这将使我们能够从顶层开始查看时间消耗的位置。

我们可以使用 perf report 查看时间消耗的位置。以下是经过轻微编辑的摘录,详细说明了 write 函数的时间消耗分布:8

% perf report -g --symbol-filter=write
-   48.05%     0.05%  write    libc-2.33.so       [.] __GI___libc_write
   - 48.04% __GI___libc_write
      - 47.69% entry_SYSCALL_64_after_hwframe
         - do_syscall_64
            - 47.54% ksys_write
               - 47.40% vfs_write
                  - 47.23% new_sync_write
                     - pipe_write
                        + 24.08% copy_page_from_iter
                        + 11.76% __alloc_pages
                        + 4.32% schedule
                        + 2.98% __wake_up_common_lock
                          0.95% _raw_spin_lock_irq
                          0.74% alloc_pages
                          0.66% prepare_to_wait_event

47% 的时间用于 pipe_write,这是当我们向管道写入数据时 write 调用的函数。这并不令人意外——我们大约花费一半时间进行写入,另一半时间用于读取。

pipe_write 中,3/4 的时间用于复制或分配页面(copy_page_from_iter__alloc_pages)。如果我们已经了解内核与用户空间之间的通信机制,这或许能解释部分现象。无论如何,要全面理解当前情况,我们必须先弄清楚管道的工作原理。

  1. 需注意此处对 shell 调用的性能分析涵盖了管道的读写操作——perf record 默认会跟踪所有子进程。↩︎
  2. 在分析此程序时,我注意到 perf 输出被 “压力停滞信息”基础设施 (PSI) 的信息污染。因此,这些数字来自一个禁用 PSI 的内核。这可以通过在内核构建配置中添加 CONFIG_PSI=n 来实现。在 NixOS 中:
     boot.kernelPatches = [{
       name = “disable-psi”;
       patch = null;
       extraConfig = ‘’ 
         PSI n 
       ‘’;
     }];
    

    此外,内核调试符号必须存在,以便 perf 正确显示系统调用期间的时间消耗位置。安装符号的方法因发行版而异。在最近的 NixOS 版本中,它们默认已安装。↩︎

  3. perf report 中,您可以使用 + 展开调用图,前提是您已运行 perf record -g↩︎

管道由什么组成? #

存储管道的数据结构位于 include/linux/pipe_fs_i.h 中找到,而对其的操作则在 fs/pipe.c 中实现。

Linux 管道是一个 环形缓冲区,其中存储了数据写入和读取的页面引用:

图1:Linux 管道的速度到底有多快?

在上图中,环形缓冲区有8个槽位,但实际数量可能更多或更少,默认值为16。在x86-64架构中,每个页大小为4KiB,但在其他架构中可能不同。总的来说,该管道最多可存储32KiB的数据。这是一个关键点:每个管道在填满之前都有一个最大数据存储上限。

图中阴影部分代表当前管道数据,非阴影部分代表管道中的空闲空间。

有些反直觉的是,head存储管道的写入端。也就是说,写入者会将数据写入由head指向的缓冲区,并在需要移动到下一个缓冲区时相应地增加head。在写入缓冲区内,len存储已写入的数据量。

相反,tail 存储管道的读取端:读取者将从该处开始消费管道。offset 指示从何处开始读取。

需要注意的是,tail 可能出现在 head 之后,如图所示,因为我们使用的是环形缓冲区。此外,当管道未完全填满时,某些槽位可能未被使用——即中间的 NULL 单元。如果管道已满(无 NULL 且页面中无空闲空间),write 将阻塞。如果管道为空(全部为 NULL),read 将阻塞。

以下是 pipe_fs_i.h 中 C 数据结构的简化版本:

struct pipe_inode_info {
  unsigned int head;
  unsigned int tail;
  struct pipe_buffer *bufs;
};

struct pipe_buffer {
  struct page *page;
  unsigned int offset, len;
};

这里省略了许多字段,我们尚未解释 struct page 的内容,但这是理解管道读写机制的关键数据结构。

管道的读写操作 #

现在让我们查看pipe_write 的定义,尝试理解之前显示的 perf 输出。

以下是对 pipe_write 工作原理的简要说明:

  1. 如果管道已满,等待空间并重新启动;
  2. 如果 head 当前指向的缓冲区有空间,先填充该空间
  3. 在有空闲槽位的情况下,以及仍有剩余字节待写入分配新页面填充它们,更新 head

 

当我们向管道写入数据时会发生什么。

上述操作由一个锁保护,pipe_write 获取并根据需要释放该锁。

pipe_readpipe_write 的镜像,区别在于我们读取页面、在完全读取后释放它们,并更新 tail9

因此,我们现在对正在发生的事情有了一个相当不愉快的画面:

  • 我们每次复制页面两次,一次从用户内存到内核,一次从内核回用户内存;
  • 复制操作以每次 4KiB 页为单位进行,期间穿插其他操作,如读写同步、页面分配和释放;
  • 我们处理的内存可能不连续,因为我们不断分配新页面;
  • 我们获取并释放管道锁。

在这台机器上,顺序 RAM 读取速度约为 16GiB/s:

  1. 一个名为tmp_page的“备用页面”实际上由pipe_read保留,并被pipe_write重复使用。然而,由于这始终仅是一个页面,我无法利用它来实现更高性能,因为页面重复使用会被调用pipe_writepipe_read时的固定开销所抵消。↩︎
% sysbench memory --memory-block-size=1G --memory-oper=read --threads=1 run...

102400.00 MiB 传输 (15921.22 MiB/秒)

考虑到上述所有复杂性,与单线程顺序内存速度相比,性能下降4倍并不令人意外。

调整缓冲区大小或管道大小以减少系统调用和同步开销,或调优其他参数都不会带来太大改善。幸运的是,有一种方法可以完全绕过writeread的低效问题。

拼接技术来救场 #

将缓冲区从用户内存复制到内核再返回的操作,常常是需要进行快速I/O操作的人们的痛点。一个常见的解决方案是直接绕过内核,直接执行I/O操作。例如,我们可以直接与网络卡交互,绕过内核以实现低延迟网络通信。

一般来说,当我们向套接字、文件或在本例中的管道写入数据时,首先会将数据写入内核中的某个缓冲区,然后让内核完成其工作。在管道的情况下,管道本身就是内核中的一系列缓冲区。如果我们追求性能,这种复制操作是不可取的。

幸运的是,Linux 提供了系统调用,可以在不进行复制的情况下加快数据在管道之间传输的速度。具体来说:

  • splice 将数据从管道移动到文件描述符,反之亦然。
  • vmsplice 将数据从用户内存移动到管道。10

关键的是,这两项操作均无需进行任何复制。

现在我们了解了管道的工作原理,可以大致想象这两种操作的运作方式:它们只是从某个地方“抓取”一个现有的缓冲区并将其放入管道环形缓冲区,或者反之,而不是根据需要分配新的页面:

  1. 从技术上讲,vmsplice 还支持反向传输数据,尽管这种方式并不实用。正如 手册页 所述:

    vmsplice 实际上仅支持从用户内存向管道进行真正的拼接。在相反方向,它实际上只是将数据复制到用户空间。

    ↩︎

我们很快就会看到这究竟是如何工作的。

实际中的拼接 #

让我们用 vmsplice 替换 write。这是 vmsplice 的签名:

struct iovec {
  void  *iov_base; // 起始地址
  size_t iov_len;  // 字节数
};

// 返回已拼接入管道的字节数
ssize_t vmsplice(
  int fd, const struct iovec *iov, size_t nr_segs, unsigned int flags
);

fd 是目标管道,struct iovec *iov 是要移动到管道的缓冲区数组。需注意,vmsplice 返回的是实际插入管道的字节数,可能并非全部数据,这与 write 返回的写入量类似。请记住,管道的容量受环形缓冲区中可用槽位数量的限制,而 vmsplice 也不例外。

使用 vmsplice 时还需格外小心。由于用户内存是直接移动到管道而非复制,我们必须确保读取端消耗完数据后才能复用拼接缓冲区。

因此 fizzbuzz 采用双缓冲方案,具体实现如下:

  1. 将 256KiB 缓冲区分为两部分;
  2. 将管道大小设置为 128KiB,这将使管道环形缓冲区具有 128KiB/4KiB = 32 个槽位;
  3. 交替对第一个半缓冲区进行写入并使用 vmsplice 将其移动到管道,然后对另一个半缓冲区执行相同操作。

将管道大小设置为 128KiB, 以及我们等待 vmsplice 完全输出一个 128KiB 缓冲区,确保在完成一次 vmsplice 迭代时,我们已知前一个缓冲区已被完全读取——否则我们将无法将新的 128KiB 缓冲区完全 vmsplice 到 128KiB 管道中。

目前我们并未实际向缓冲区写入任何数据,但仍保留双缓冲方案,因为任何实际写入内容的程序均需采用类似方案。11

我们的写入循环现在大致如下:

  1. Travis Downs 指出 该方案仍可能存在安全隐患,因为页面可能被进一步拼接,从而延长其生命周期。这个问题在原版的 FizzBuzz 帖子中也存在。实际上,我并不完全清楚 vmsplice 在没有 SPLICE_F_GIFT 时是否真的不安全——vmsplice 的手册页暗示它不应该不安全。然而,要实现零拷贝管道传输同时保持安全性,确实需要特别注意。在测试程序中,读取端将管道拼接到/dev/null,因此内核可能知道这些页面可以不进行复制即可拼接,但我尚未验证这是否确实是实际发生的情况。↩︎
int main() {
  size_t buf_size = 1 << 18; // 256KiB
  char* buf = malloc(buf_size);
  memset((void*)buf, 'X', buf_size); // output Xs
  char* bufs[2] = { buf, buf + buf_size/2 };
  int buf_ix = 0;
  // Flip between the two buffers, splicing until we're done.
  while (true) {
    struct iovec bufvec = {
      .iov_base = bufs[buf_ix],
      .iov_len = buf_size/2
    };
    buf_ix = (buf_ix + 1) % 2;
    while (bufvec.iov_len > 0) {
      ssize_t ret = vmsplice(STDOUT_FILENO, &bufvec, 1, 0);
      bufvec.iov_base = (void*) (((char*) bufvec.iov_base) + ret);
      bufvec.iov_len -= ret;
    }
  }
}

以下是使用 vmsplice 而不是 write 进行写入的結果:

% ./write --write_with_vmsplice | ./read
12.7GiB/s, 256KiB 缓冲区, 40960 次迭代 (10GiB 管道传输)

这将我们需要进行的复制操作减少了一半,并且已经将吞吐量提高了三倍多——达到 12.7GiB/s。将读取端改为使用 splice,我们消除了所有复制操作,并获得了另外 2.5 倍的加速:

% ./write --write_with_vmsplice | ./read --read_with_splice
32.8GiB/s,256KiB缓冲区,40960次迭代(10GiB管道传输)

页面管理优化 #

下一步该做什么?让我们询问perf

% perf record -g sh -c ‘./write --write_with_vmsplice | ./read --read_with_splice’
33.4GiB/s, 256KiB 缓冲区, 40960 次迭代 (10GiB 管道传输)
[ perf record: 因写入数据而被唤醒 1 次 ]
[ perf record: 捕获并写入 0.305 MB perf.data (2413 个样本) ]
% perf report --symbol-filter=vmsplice
-   49.59%     0.38%  write    libc-2.33.so       [.] vmsplice
   - 49.46% vmsplice
      - 45.17% entry_SYSCALL_64_after_hwframe
         - do_syscall_64
            - 44.30% __do_sys_vmsplice
               + 17.88% iov_iter_get_pages
               + 16.57% __mutex_lock.constprop.0
                 3.89% add_to_pipe
                 1.17% iov_iter_advance
                 0.82% mutex_unlock
                 0.75% pipe_lock
        2.01% __entry_text_start
        1.45% syscall_return_via_sysret

绝大多数时间都花在锁定管道以进行写入(__mutex_lock.constprop.0)以及将页面移动到管道中(iov_iter_get_pages)上。对于锁定操作,我们能做的有限,但我们可以改进iov_iter_get_pages的性能。

如其名称所示,iov_iter_get_pages 将传递给 vmsplicestruct iovec 转换为 struct page,以便放入管道中。要理解该函数的实际工作原理以及如何加速它,我们必须先了解 CPU 和 Linux 如何组织页面。

页面管理快速概述 #

如您所知,进程不会直接引用内存中的位置:相反,它们被分配了虚拟内存地址,这些地址会被解析为物理地址。这种抽象称为虚拟内存,它具有各种优势,我们在此不做详细讨论——最明显的是,它显著简化了多个进程竞争同一物理内存的运行。

无论如何,每次执行程序并从/向内存加载/存储数据时,CPU都需要将虚拟地址转换为物理地址。存储每个虚拟地址到对应物理地址的映射是不切实际的。因此内存被划分为大小均匀的块,称为页面,虚拟页面被映射到物理页面:12

4KiB 并没有什么特别之处:每种架构都会根据各种权衡因素选择一个大小——其中一些我们稍后会探讨。

为了使这更精确,让我们想象使用 malloc 分配 10000 字节:

void* buf = malloc(10000);
printf(“%p\n”, buf);          // 0x6f42430
  1. 这里我们呈现了一个简化的模型,其中物理内存是一个简单的平坦、线性序列。现实情况要复杂一些,但这个简单模型足以满足我们的需求。↩︎

当我们使用它们时,这 10k 字节在虚拟内存中看起来是连续的,但会被映射到 3 个不一定连续的物理页面:13

  1. 您可以通过读取 /proc/self/pagemap 文件(如本博客之前的文章中所示[../posts/check-huge-page.html]),并乘以“页面帧号”和页面大小,来查看当前进程虚拟页面的物理地址。↩︎

内核的一项任务是管理此映射,这体现在一个名为“页表”的数据结构中。CPU 指定页表的结构(因为它需要理解它),而内核根据需要对其进行操作。在 x86-64 架构中,页表是一个 4 级、512 路树,它本身驻留在内存中。14 该树的每个节点(你猜对了!)宽度为 4KiB,节点内指向下一层的每个条目为 8 字节(4KiB/8 字节 = 512)。条目中包含下一节点的地址以及其他元数据。

每个进程都有一个页面表——换句话说,每个进程都有一个预留的虚拟地址空间。当内核切换到某个进程时,它会将特殊寄存器CR3设置为该树根节点的物理地址。15 然后,每次需要将虚拟地址转换为物理地址时,CPU会将地址分成几个部分,并使用这些部分遍历该树以计算物理地址。

为了让这些概念更直观,以下是虚拟地址 0x0000f2705af953c0 可能解析为物理地址的可视化示意图:

  1. 自Ice Lake起,Intel将页表扩展为5个层次,从而将可寻址内存的最大容量从256TiB提升至128PiB。然而,此功能需显式启用,因为部分程序依赖指针的高16位保持未被使用。↩︎
  2. 页表中的地址必须是物理地址,否则我们将面临无限循环。↩︎

搜索从第一层开始,称为“页全局目录”(PGD),其物理位置存储在CR3中。地址的前16位未被使用。16 我们使用接下来的 9 位来访问 PGD 条目,并向下遍历到第二级,即“页面上层目录”(PUD)。接下来的 9 位用于从 PUD 中选择一个条目。此过程在接下来的两级(PMD“页面中层目录”和 PTE“页面表条目”)中重复。PTE指示我们正在查找的实际物理页的位置,然后我们使用最后12位来查找页面内的偏移量。

页面表的稀疏结构允许映射随着新页面的需要逐步构建。每次进程需要内存时,内核都会在页面表中添加一个新条目。

  1. 注意最高 16 位未被使用:这意味着每个进程最多可寻址 2^{48}-1 字节(即 256TiB)的物理内存。↩︎

struct page 的作用 #

struct page 数据结构是这一机制的关键组成部分:它是内核用于引用单个物理页面的结构,存储其物理地址以及各种其他元数据。17 例如,我们可以从页面表的最后一级(即 PTE)中获取 struct page。在处理与页面相关的所有代码中,它被广泛使用。

在管道的情况下,struct page 用于在环形缓冲区中存储其数据,正如我们已经看到的:

struct pipe_inode_info {
  unsigned int head;
  unsigned int tail;
  struct pipe_buffer *bufs;
};

struct pipe_buffer {
  struct page *page;
  unsigned int offset, len;
};
  1. struct page 还可能指代尚未分配的物理页面(这些页面尚未拥有物理地址)以及其他与页面相关的抽象概念。可以将它们视为对物理页面的抽象引用,但不一定是已分配物理页面的引用。这个微妙的区别将在后面的附注中有所涉及 🫠。↩︎

然而,vmsplice 接受虚拟内存作为输入,而 struct page 直接引用物理内存。

因此,我们需要将任意块的虚拟内存转换为一组 struct page。这正是 iov_iter_get_pages 所做的工作,也是我们花费一半时间的地方:

ssize_t iov_iter_get_pages(
  struct iov_iter *i,  // 输入:虚拟内存中的一个带大小的缓冲区
  struct page **pages, // 输出:支持输入缓冲区的页面列表
  size_t maxsize,      // 获取的最大字节数
  unsigned maxpages,   // 获取的最大页面数
  size_t *start        // 输入缓冲区未对齐时,第一个页面的偏移量
);

struct iov_iter 是 Linux 内核中的数据结构,用于表示以各种方式遍历内存块,包括 struct iovec。在我们的案例中,它将指向一个 128KiB 的缓冲区。vmsplice 将使用 iov_iter_get_pages 将输入缓冲区转换为多个 struct page,并保留它们。现在你已经了解了分页的工作原理,你可能大致能想象 iov_iter_get_pages 的工作方式,但我们将在下一节中详细解释。

我们快速介绍了许多新概念,因此简要回顾一下:

  • 现代 CPU 为其进程使用虚拟内存;
  • 内存以固定大小的页面组织;
  • CPU 使用页面表将虚拟地址映射到物理地址,其中页面表将虚拟页面映射到物理页面;
  • 内核根据需要向页面表添加或移除条目;
  • 管道由对物理页面的引用组成,因此 vmsplice 必须将虚拟内存范围转换为物理页面,并保留这些页面。

获取页面的成本 #

iov_iter_get_pages 中花费的时间实际上完全用于另一个函数 get_user_pages_fast

% perf report -g --symbol-filter=iov_iter_get_pages
-   17.08%     0.17%  write    [kernel.kallsyms]  [k] iov_iter_get_pages
   - 16.91% iov_iter_get_pages
      - 16.88% internal_get_user_pages_fast
           11.22% try_grab_compound_head

get_user_pages_fastiov_iter_get_pages 的一个更简化的版本:

int get_user_pages_fast(
  // 虚拟地址,页面对齐
  unsigned long start,
  // 要检索的页面数量
  int nr_pages,
  // 标志,其含义我们在此不做详细说明
  unsigned int gup_flags,
  // 输出物理页面
  struct page **pages
)

这里“用户”(相对于“内核”)指的是我们将虚拟页面转换为物理页面的引用。

为了获取 struct pageget_user_pages_fast 做了与 CPU 相同的事情,但通过软件实现:它遍历页表以收集所有物理页面,并将结果存储在 struct page 中。在我们的情况下,我们有一个 128KiB 的缓冲区,页面大小为 4KiB,因此 nr_pages = 3218 get_user_pages_fast 需要遍历页面表树,收集 32 个叶节点,并将结果存储在 32 个 struct page 中。

get_user_pages_fast 还需要确保物理页面在调用者不再需要它之前不会被重新分配。这在内核中通过引用计数实现存储在 struct page 实现,用于确定何时可以释放物理页面并在未来重新分配。调用 get_user_pages_fast 的程序必须在某个时刻使用 put_page,这将减少引用计数。

最后,get_user_pages_fast 的行为会根据虚拟地址是否已存在于页面表中而有所不同。这就是 _fast 后缀的由来:内核会首先尝试通过遍历页面表来获取已存在的页面表条目及其对应的 struct page,这相对较快,否则会退而求其次,通过其他更耗时的手段生成 struct page。我们在内存开头使用 memset 填充内存,可确保永远不会进入 get_user_pages_fast 的“慢速”路径,因为页面表条目会在缓冲区被 ‘X’ 填充时自动创建。19

需要注意的是,get_user_pages 函数家族不仅对管道有用——事实上,它在许多驱动程序中都起着核心作用。一个典型的用法与我们提到的内核旁路相关:网络卡驱动程序可能使用它将某些用户内存区域转换为物理页面,然后将物理页面的位置传递给网络卡,让网络卡直接与该内存区域交互,而无需内核参与。

  1. 实际上,管道代码恰好总是调用get_user_pages_fast并设置nr_pages = 16,必要时循环调用,可能是为了使用一个小的静态缓冲区。但这是实现细节,拼接的页面总数仍为 32。↩︎
  2. 后续细节较为复杂,理解本文其余内容无需关注!如果页面表中不包含我们要查找的条目,get_user_pages_fast 仍需返回一个 struct page。最明显的做法是创建正确的页面表条目,然后返回对应的 struct page。然而,get_user_pages_fast 仅在被要求获取用于写入的 struct page 时才会这样做。否则,它将不会更新页面表,而是返回一个 struct page,该结构体指向一个尚未分配的物理页面。这正是 vmsplice 场景下的情况,因为我们只需生成一个 struct page 以填充管道,而无需实际写入内存。换句话说,页面分配会被推迟到实际需要时再进行。这节省了分配物理页面的开销,但如果页面从未通过其他方式被故障引入,将导致 get_user_pages_fast 的慢路径被反复调用。因此,如果我们在调用 get_user_pages_fast 之前没有进行 memset 操作,从而没有手动将页面加载到页面表中,那么不仅第一次调用 get_user_pages_fast 时会进入慢速路径,后续所有调用也会进入慢速路径,导致显著的性能下降(从 30GiB/s 降至 25GiB/s):
```
% ./write --write_with_vmsplice --dont_touch_pages | ./read --read_with_splice
25.0GiB/s, 256KiB 缓冲区, 40960 次迭代 (10GiB 管道传输)
```

此外,这种行为在使用大页时不会出现:在这种情况下,`get_user_pages_fast` 会正确地将页面加载到内存中,当传递的虚拟内存范围由大页支持时。

如果这一切都让人感到困惑,别担心,`get_user_pages` 和相关函数似乎是内核中一个非常棘手的角落,[即使对于](https://lwn.net/Kernel/Index/#Memory_management-get_user_pages) [内核开发者](https://github.com/torvalds/linux/blob/f443e374ae131c168a065ea1748feac6b2e76613/Documentation/core-api/pin_user_pages.rst)来说也是个棘手的问题。[↩︎](#fnref19)

大页面 #

到目前为止,我们一直将页面视为始终具有相同大小——在 x86-64 架构上为 4KiB。然而,许多 CPU 架构(包括 x86-64)支持更大的页面大小。在 x86-64 架构中,我们不仅有 4KiB 页面(“标准”大小),还有 2MiB 甚至 1GiB 页面(“大页面”)。在本文的其余部分,我们将仅讨论 2MiB 大页面,因为 1GiB 页面较为罕见,而且对于我们的任务来说过于冗余。

架构 最小页面大小 更大页面大小
x86 4KiB 2MiB, 4MiB
x86-64 4KiB 2MiB, 1GiB20
ARMv7 4KiB 64KiB, 1MiB, 16MiB
ARMv8 4KiB 16KiB, 64KiB
RISCV32 4KiB 4MiB
RISCV64 4KiB 2MiB, 1GiB, 512GiB, 256 TiB
Power ISA 8KiB 64 KiB, 16 MiB, 16 GiB

当前常用架构中可用的页面大小,来自维基百科#Multiple_page_sizes)。

巨大页的主要优势在于管理成本更低,因为覆盖相同内存量所需的巨大页数量更少。此外,其他操作的成本也更低,例如将虚拟地址解析为物理地址,因为所需的页表级别减少了一级: instead of having a 12-bit offset into the page, we’ll have a 21-bit offset, and one less page table level.

  1. 仅当 CPU 具有 PDPE1GB 标志时。↩︎

这减轻了 CPU 中负责此转换的组件的压力,从而在许多情况下提升了性能。21 然而,在我们的情况下,压力并非来自遍历页面表的硬件,而是来自其在内核中运行的软件对应部分。

在 Linux 中,我们可以以多种方式分配 2MiB 大页 例如,例如通过分配对齐到 2MiB 的内存,然后使用 madvise 告知内核为提供的缓冲区使用大页:

void* buf = aligned_alloc(1 << 21, size);
madvise(buf, size, MADV_HUGEPAGE)

在我们的程序中切换到巨大页面可带来约 50% 的性能提升:

% ./write --write_with_vmsplice --huge_page | ./read --read_with_splice
51.0GiB/s,256KiB 缓冲区,40960 次迭代(10GiB 管道传输)
  1. 例如,CPU 包含专门的硬件用于缓存页表的一部分,即“翻译查找块缓冲区”(TLB)。TLB 在每次上下文切换时都会被清空(每次我们更改 CR3 的内容时)。大页面可以显著减少 TLB 缺失,因为一个 2MiB 页面的条目覆盖的内存是 4KiB 页面的 512 倍。↩︎

然而,改进的原因并不完全明显。直观地,我们可能会认为使用大页面时,struct page 只需引用一个 2MiB 页面,而不是 4KiB。

遗憾的是,事实并非如此:内核代码在任何地方都假设 struct page 指向当前架构的“标准”大小页面。对于巨大页面(以及 Linux 所称的“复合页面”),其工作原理是:一个“头部” struct page 包含实际的物理页面信息,而后续的“尾部”页面仅包含指向头部页面的指针。

因此,要表示 2MiB 大页面,我们将有 1 个“头部”struct page,以及多达 511 个“尾部”struct page。或者在我们的 128KiB 缓冲区案例中,有 31 个尾部 struct page22

即使需要所有这些 struct page,生成它们的代码最终会显著更快。无需多次遍历页面表,一旦找到第一个条目,后续的 struct page 即可通过 简单循环生成。因此性能得到了提升!

  1. 如果你在想“这太糟糕了!”,你并不孤单。目前正在进行各种努力来简化并优化这种情况。最近的内核版本(从5.17开始)引入了一种新类型,即struct folio,用于明确标识头页。这减少了在运行时检查 struct page 是否为头页或尾页的需要,从而提升了性能。其他努力 旨在直接移除额外的 struct page,尽管我对进展情况并不了解。↩︎

Busy looping #

我们快完成了,我保证!让我们再次查看 perf 输出:

-   46.91%     0.38%  write    libc-2.33.so       [.] vmsplice
   - 46.84% vmsplice
      - 43.15% entry_SYSCALL_64_after_hwframe
         - do_syscall_64
            - 41.80% __do_sys_vmsplice
               + 14.90% wait_for_space
               + 8.27% __wake_up_common_lock
                 4.40% add_to_pipe
               + 4.24% iov_iter_get_pages
               + 3.92% __mutex_lock.constprop.0
                 1.81% iov_iter_advance
               + 0.55% import_iovec
            + 0.76% syscall_exit_to_user_mode
        1.54% syscall_return_via_sysret
        1.49% __entry_text_start

我们现在花费了大量时间等待管道可写入(wait_for_space),并唤醒那些等待管道有内容的读者(__wake_up_common_lock)。

为了规避这些同步开销,我们可以让vmsplice在管道无法写入时立即返回,并循环等待直到管道可写入——读取时使用splice时同样如此:


// SPLICE_F_NONBLOCK 将导致`vmsplice`在无法写入管道时立即返回
// 并返回EAGAIN错误
ssize_t ret = vmsplice(STDOUT_FILENO, &bufvec, 1, SPLICE_F_NONBLOCK);
if (ret < 0 && errno == EAGAIN) {
  continue; // 如果无法写入,则进行忙循环
}...

通过 Busy looping,我们获得了另外 25% 的性能提升:

% ./write --write_with_vmsplice --huge_page --busy_loop | ./read --read_with_splice --busy_loop
62.5GiB/s, 256KiB 缓冲区, 40960 次迭代 (10GiB 管道传输)

显然,忙循环会占用一个 CPU 核心,等待 vmsplice 就绪。但这种权衡往往是值得的,事实上这是高性能服务器应用程序的常见模式:我们用可能浪费的 CPU 利用率来换取更好的延迟和/或吞吐量。

在我们的案例中,这标志着我们对这个小型合成基准测试的优化之旅告一段落,从 3.5GiB/s 提升至 65GiB/s。

结语 #

我们通过分析 perf 输出和 Linux 源代码,系统性地提升了程序的性能。管道和拼接在高性能编程中并非热门话题,但我们涉及的主题包括:零拷贝操作、环形缓冲区、分页与虚拟内存、同步开销。

还有一些细节和有趣的话题我未提及,但这篇博客文章已经变得过于冗长:

  • 在实际代码中,缓冲区是单独分配的,以减少页面表竞争,将其放置在不同的页面表条目中(FizzBuzz 程序也采用了这种做法)。请记住,当使用 get_user_pages 获取页面表条目时,其引用计数会增加,而在 put_page 时会减少。如果我们为两个缓冲区使用两个页面表条目,而不是将它们放在同一个页面表条目中,那么在修改引用计数时会减少竞争。
  • 测试是通过使用 taskset./write./read 进程固定到两个核心上运行的。
  • 仓库中的代码包含许多其他我尝试过的选项,但最终没有提及,因为它们与主题无关或不够有趣。
  • 仓库中还包含一个用于 get_user_pages_fast 的合成基准测试,可用于精确测量启用或禁用巨大页面时该函数的运行速度差异。
  • 拼接(splicing)作为一个概念本身略显可疑/危险这一问题持续困扰着内核开发者。

请告诉我这篇文章是否对您有帮助、有趣或存在不清晰之处!

本文文字及图片出自 How fast are Linux pipes anyway?

共有 348 条讨论

  1. 将一个基于管道的 Linux 应用程序移植到 Windows 上的经历深深烙印在我的灵魂中。我原本以为所有操作都遵循 POSIX 标准,而且由于所有操作都在内存中进行,性能应该差不多。然而,性能糟糕透顶,即使我们发现管道在等待连接时几乎让 Windows 系统瘫痪。

    几年后,由于需要在Windows 10上使用C#实现相同功能,这个问题再次被提起。虽然性能有所改善,但性能差距之大仍令人尴尬。

    1. 几年前Windows添加了AF_UNIX套接字,我好奇它们与Win32管道相比性能如何。我的猜测是性能会更好。

        1. 我们查看的是同一组数据吗?它似乎比命名管道快约3倍,且比本地TCP略快。

          值得注意的是,在Win32系统中,无名管道本质上就是去掉名称的命名管道。因此,这个“3倍更快”的对比,我认为正是我们感兴趣的直接对比。

    2. > 性能非常糟糕,即使我们发现管道在等待连接时几乎让 Windows 陷入瘫痪。

      当你说性能糟糕时,是指管道已经连接/打开后的 I/O,还是之前?前者会令人惊讶,但后者不会——打开和关闭大量管道不是操作系统优化优化的对象,而且如果你的用例需要后者,那会有些令人惊讶。

      1. 仅仅拥有备用监听套接字,准备接收传入连接(当然不会在这些套接字上进行忙等待)。将数量减少到实际使用的数量是最大的性能提升——这就像Windows在内部忙等待新连接(数量也不是很大,大概8或12个)。

        1. 您所说的“备用监听套接字”是指服务器端线程调用ConnectNamedPipe吗?您的术语让我有些困惑,因为这些并不被称为监听套接字。(您不是在指socket()或AF_UNIX吧?)

          是的,这大致符合我的预期。该实现可能针对已建立连接的重复I/O进行了优化,而非针对未建立连接的重复操作。这与Windows文件系统I/O的优化逻辑类似——它针对打开文件(尤其是大文件)的I/O进行了优化,而非针对反复打开和关闭文件(尤其是小文件)的操作。这让我好奇,有哪些使用场景需要在命名管道上进行重复连接。

          如果连接后的性能与 Linux 相当,那么我认为这一点值得注意——因为这对许多应用程序来说很重要。

            1. 此函数仅为与 16 位 Windows 应用程序兼容而提供。应用程序应将初始化信息存储在注册表中。

              他们实际上从 16 位 Windows 时代就开始提供注册表来解决这个问题。在 2025 年,当他们已经为你提供了数十年的完美替代方案时,还以此为由责怪他们,这实在荒谬,且恰恰证明了与你意图相反的事实。

          1. 是的,确实使用了ConnectNamedPipe——我刚查看了代码(无法分享)以刷新记忆。主要问题被追溯到WaitForSingleObject()/WaitForMultipleObjects()的设置延迟;我们按上述方式修复了它(一旦所有会话连接完成,就不再有备用资源,因此没有问题),实际吞吐量被记录为远低于Linux,但对我们的应用程序来说已经足够,所以我们保留了它。

            1. 啊,有意思,谢谢你检查。我不太确定等待发生在何处,但我的猜测是,微软设计监听机制的方式是:当现有监听器连接到客户端时,才 spawns 一个新的管道监听器(如果需要的话)。这样你就不用提前 spawns 8 个,而是 spawns 1 个,然后计数到 8。

              我直觉上认为,在所有客户端连接后,整个过程应与Linux类似,除非Linux使用了如vmsplice()之类的系统调用——但不确定,我从未进行过基准测试。

    3. 据我所知,在Windows上本地TCP性能远超管道。

    4. POSIX 仅定义行为,而非性能。每个平台和操作系统都会有其独特的性能特性。

      1. POSIX 究竟如何定义管道之类的性能?

        1. 我指的是“这都是 POSIX 标准,既然都在内存中,性能大致相同。”

          我并非主张 POSIX 应该或能够尝试解决性能问题。

        2. 一些标准确实定义了性能要求,例如对数据结构的操作,使用 BigO 表示法。

        3. 通过使用 Big O 表示法,或像实时操作系统 API 中的截止时间,作为在标准中表达性能的两种可能示例。

    5. 我记得多年前,我们有过相反的经历。不一定是管道。我们在Linux上运行一个PHP应用程序,该应用程序与.NET上的SOAP API通信,发现.NET实现的响应时间更好。

    6. 你是否发现需要使用进程间通信来弥补差距?

      1. 管道是一种进程间通信的形式 🙂 我猜你指的是共享内存?

        1. 是的。没错,你说得对。套接字也可以使用,但当我想到 IPC 时,我通常会想到共享内存。

  2. 顺便说一下,还有readv()/writev()、splice()、sendfile()、funopen()和io_buffer()等函数。

    splice()在通过管道和UNIX套接字进行零拷贝数据传输时非常出色,但它仅限于Linux系统。

    splice()是通过管道传输数据最快且最高效的方式(在Linux上),尤其适用于大量数据传输。它绕过了用户空间的内存分配(与 read(v)/write(v) 不同),没有额外的缓冲区管理逻辑,没有 memcpy() 或 iovec 遍历。

    遗憾的是,在 BSD 系统中,对于管道,readv() / writev() 是实现相同功能的最优性能方式,如果我没有记错的话。请纠正我如果我错了。

    无论如何,这是一篇很棒的文章。

    1. > sendfile() 是文件到套接字(同样是零拷贝)的操作,在 Linux 和 BSD 系统上都具有非常高的性能。然而,它仅支持文件到套接字的传输。此外,为了保持相关性,sendmsg() 不能在一般情况下与管道配合使用,它专用于 UNIX 域套接字、INET 套接字和其他套接字类型。

      在 Linux 上,sendfile 支持的不仅仅是文件到套接字的传输,因为它是通过 splice 实现的。我过去曾用它进行过文件到块设备的传输。

      1. 在BSD系统上可能不行,因为它们没有splice,但这值得了解。我好奇在BSD系统上,是否真的是readv()和writev()是实现与文章中相同功能最快的方式。也许我漏掉了什么。欢迎指正。

        1. 据我所知,OpenBSD和NetBSD都没有sendfile。在FreeBSD上,我认为你关于它仅支持文件到套接字的观点是正确的。

          1. 确实,如果我没记错的话,Netflix至少曾经使用(并提交到内核)FreeBSD作为内容服务器,因为其sendfile性能更优。

    2. > splice() 是通过管道传输数据最快且最高效的方式(在 Linux 上),尤其适用于大容量数据。它绕过了用户空间的内存分配(与 read(v)/write(v) 不同),没有额外的缓冲区管理逻辑,也没有 memcpy() 或 iovec 遍历。

      正确使用 io_uring 应该能够超越或至少与之匹敌。

    3. 共享内存,如 shm_open 和文件描述符传递,会更快且完全可移植。

  3. 这篇文章太棒了。我喜欢它不时出现。

  4. 我感到遗憾的是这篇文章没有评论,但文章本身真的很棒。

    我希望更多地使用splice,但文章结尾提到了安全影响和一些ABI兼容性问题。

    我很好奇长期计划是否会继续保留splice?

    我也想知道将默认管道修改为始终使用splice以提升性能的难度如何。

  5. 现代 Linux 是否有类似 Doors 的工具?我有一个嵌入式应用程序,其中两个进程需要交换小量且对延迟敏感的数据,我想知道是否有比 AF_UNIX 更好的解决方案。

    1. 共享内存提供最低的延迟,但你仍然需要处理任务唤醒,这通常是通过futexs实现的。谷歌曾为Linux开发了一个FUTEX_SWAP调用,该调用本可以实现任务之间的直接交接,但不确定该项目后来如何了。

      1. 如果你真的想要低延迟,那么你可以接受用功耗/CPU来换取它,你可以选择自旋而不是被唤醒。

    2. 什么是Doors,这是一个太常见的词,无法通过谷歌搜索到。

    3. 了解您目前对AF_UNIX的具体问题会很有帮助。是缺少您需要的特性吗?是延迟高于预期吗?还是服务器/客户端套接字API风格不适合您的用例?

      1. 嗯,可能没问题,但这是一个音频应用程序,其中计量(非音频)数据从控制平面进程传递到用户界面进程。更低的延迟更好。但尚未进行测量。

  6. 如果我理解正确的话,vmsplice更像是两个进程之间的一种小型共享内存机制,如果在读取和写入端同时使用?这意味着两个进程在读写缓冲区以及使用后如何返回时都需要格外小心。既令人兴奋又令人害怕。

    另一个主要收获是,大家都会写的简单实现,比可能实现的要慢20倍。

    顺便说一句,这是一篇写得非常好的文章。

    1. 如果你尝试实现20倍更快的版本,你的同事会认为你在过度复杂化问题且不配合团队。

      1. 你的同事更希望你将系统拆分为两个通过REST API通信的微服务,也就是200倍更慢的版本。

        1. 没错。然后他们会纳闷为什么半夜收到页面,因为CustomerPaymentsProcessingService与CustomerMembershipProcessingService和CustomerOrderStatusService不一致,导致人们的信用卡被扣款而会员状态未显示为活跃,或者其他什么问题。

          或者他们可能不会纳闷,因为这让他们觉得自己很重要?

        2. 一个没有超文本痕迹的REST API。

        1. 这是否有益?我还是个新手,不太清楚。

          1. (假设我们讨论的是AWS无服务器函数)和其他事物一样,这取决于具体情况。

            Lambda的优势包括部署简便(无需担心服务器,这正是无服务器架构的核心),几乎无限的水平扩展能力,以及非常慷慨的免费层。

            缺点包括相对较慢的冷启动、难以对外暴露(例如通过HTTP路由),以及缺乏状态管理。

            个人而言,我喜欢将Lambda用作AWS生态系统不同部分之间的粘合剂,或用于处理事件、分发通知等。

            然而,我绝不会将Lambda用于任何类似于有状态的Web应用程序的场景。缓慢的冷启动和固有的无状态特性会让这变得困难。此外,API Gateway 非常难以使用,或者说上次我查看时是这样。

        1. 我有一长串列表,其中包含一些很好、运行良好但最终被拒绝的项目,因为团队不愿意花时间学习它们的工作原理。

          到目前为止,我的结论是,如果你想让事情运作良好,你不应该参与商业项目,而是使用一种不流行的语言,它有陡峭的学习曲线,以筛选出那些会拖累你项目的人。也许你不需要成为一个混蛋,但直率是有帮助的。

          以下是一些旨在改进项目的举措及其因其他程序员的懒惰和/或无知而失败的例子。

          当ActionScript流行时,它与HaXe竞争。HaXe是一种类似的(也与ECMAScript相关)语言,拥有一个小而专注的社区,一个远优于MXMLC(Adobe官方AS3编译器)的编译器,以及一系列旨在提高代码正确性和性能的功能。

          我被一家开发“在线 PowerPoint”类产品的公司聘用。该系统的核心组件是一个大型 Flex(AS3)应用程序,其网络利用效率极低,尤其在加载共享库(主要包含剪贴画资源)时表现糟糕。每次用户需要编辑或查看演示文稿时,系统都必须加载这些庞大的共享库。亚马逊云服务(AWS)的账单正以危险的速度增长。我的任务是寻找解决方案以减少网络活动。

          我的想法是创建一个独立的播放器组件,该组件会在服务器端从库中提取相关资源,并将其编译成独立的SWF文件。这样做的原因是,最终的演示文稿会被频繁加载,且主要由首次用户使用(即没有缓存)。HaXe是理想的语言,因为它已经有一个库可以生成SWF的大部分内容,并且它可以编译为AS3和C++,因此生成过程也可以在服务器上使用更高效的实现来完成。

          经过几个月的努力,我开发了一套程序,可以在服务器端和客户端生成SWF文件,并展示了这将如何改善网络活动。AS3团队的其他程序员,此前曾承诺会熟悉HaXe,因为他们需要将新的播放器组件集成到现有的Flex应用程序中……但他们并未履行承诺。无论我提供多少帮助,他们就是不肯采取任何行动来集成新组件,反而随着时间推移,他们的说法变得越来越离奇且不真实。

          在花费更多时间试图说服团队采用我的代码而非实际编写代码后,我决定寻找其他工作机会。最终,整个努力付诸东流。

          —-

          以非常相似的方式,我不得不解决使用Google的Protobuf Python绑定时遇到的问题,该绑定需要生成Python模块才能正常工作。我们需要一个API服务器,能够同时从名称相似的模块中提供同一Protobuf API的多个版本。由于Google的实现不支持这一点,我自行编写了替代方案(在我经理产假期间)。我优化了解析速度、网络负载,并通过支持在运行时动态添加新的Protobuf消息定义,减少了组件的维护工作量…

          问题在于我用C语言编写了解析器。正是这一点确保了良好的性能。当我的经理回到工作岗位时,她意识到自己曾声明不了解C语言且永远不会学习(尽管她与该项目无关),于是该项目被搁置。

          —-

          我还有一个类似的故事,关于从RoR应用中提取并聚合Web接口,生成Swagger定义。用Prolog编写,也因Prolog而被弃用。同样,我曾用Prolog编写了一个分布式文件系统的I/O测试工具,也因相同原因被弃用…如果我继续列举因程序员不愿学习如何做好本职工作而被弃用的项目,这个回答最终会达到字符限制。

          1. 你遗漏的部分是,你最终会离开公司,而现在有一个定制化的特殊系统,没有人知道它在后台做着什么转译工作。考虑到软件工程师的平均任期只有几年,可维护性和标准化比任何事情都重要。这就是为什么像Go语言这样有明确立场且简单的语言会获得如此大的 popularity。

            我同意,真正的软件卓越和工艺在企业环境中是罕见的。如果你想开发像curl或sqlite这样的优秀软件,你只能靠自己完成,而且很可能无法靠此谋生。

          2. 你提到的故事中有几点值得注意。首先,我确实感同身受,因为在我刚入行时也做过类似的事情。

            1)

            每次引入新框架时,通常需要广泛共识、明确范围以及对新方向的承诺,尤其当引入新语言时更是如此。你在开始这些项目时是否讨论过具体要做的事情?

            如果我管理一个RoR项目,有人离开一两周后回来带着用Prolog编写的项目,我会非常生气。谁来维护这些代码?为什么没有讨论过这个问题?

            你考虑过代码审查吗?如果其他人都不了解这种语言,更不用说深入了解,你如何进行有意义的代码审查?

            我理解如果你是唯一一个使用它的人,你可以用任何语言编写一次性工具,但在商业项目中,最明智的做法是坚持团队的优势。

            2)

            我花了一些时间才接受这样一个事实:并非所有在软件行业工作的专业人士都对技术/软件/语言充满热情。有些人只是把这当作一份工作,更愿意遵循已知且熟悉的道路,而不是探索这个领域(一位老上司经常区分“工作型”和“技术型”人员,即那些对技术充满热情的人)。

            往往,追求完美会成为阻碍进步的绊脚石。在软件开发领域,对于绝大多数情况而言,专业工作的核心在于快速实现最小可行产品(MVP)并确保代码的可维护性。有时,将时间投入到其他地方会更具价值。

            将你的热情投入到个人项目和/或开源项目中,而非工作(除非工作环境允许这样做)。

            如今,初创公司是将热情与工作相结合的绝佳场所,也是上述规则的例外。通常,这里有大量空间进行实验,并提出巧妙、复杂或突破常规的解决方案。

            1. > 每次引入新框架时,通常需要广泛共识、明确范围以及对新方向的承诺,尤其当涉及引入新语言时更是如此。你是否在未讨论具体工作内容的情况下就着手这些项目?

              如我之前所说。若我追求质量,则由我独自做出决策。若有人想加入,必须接受我的条件。我会将条件设定得毫不妥协且令那些刻意追求共识的人感到不适,因为我不想与追求共识的人合作——他们编程水平太差。

              在我的日常工作中,我与那些追求共识的人合作,他们找借口偷懒,假装工作,只求达到最低标准领薪水回家,看电视、弹吉他,随心所欲。他们不会产出任何接近高质量的工作。永远不会。这不是他们的目标,他们也不为此感到内疚。

              > 谁来维护这个?为什么没有讨论过?

              懂编程的人会负责。

              没讨论是因为我不会在意一个不懂编程的人的意见。就像我做编程决策时不会去问邻居的猫一样。

              > 你考虑过代码审查吗?……如果没人懂这门语言且_真正精通它_?

              是的,我考虑过。这是他们的问题,不是我的。如果你想成为程序员,就应该熟练掌握自己的工具。如果你想拿高薪却成为企业世界中无用的蛀虫……那你就制定更适合自己的规则吧……

              > 沉迷于技术

              这与“热爱技术”无关。这是关于那些被雇佣为程序员但实际上并非如此的人的输出质量。一个人可以“热爱”却表现糟糕,而另一个人可以“讨厌”却在自己的领域非常出色。这种情况之所以存在,是因为人性使然,人们倾向于走向一个可以懒惰和无知的方向,而这种倾向没有遇到任何阻力。

              而在其他许多职业中,高质量产品都有市场,比如非常昂贵且质量极高的手表、汽车、摄影设备、服装、食品……但在编程领域,没有市场会为追求质量而非上市时间、价格或覆盖范围的产品买单。在编程市场中,不存在愿意为更高质量产品支付十倍或百倍价格的细分市场。这就是为什么在工业环境中,没有人试图制作高质量产品,尽管有些人天真地带着这个想法进入行业,但他们很快就会被现实击碎——没有人会在乎。

          3. 难道将Haxe代码移植到AS3不是更简单吗?据我所知,这两种语言非常相似。这样你就不会遇到问题,也不必依赖他们来学习你的代码库。同样,我怀疑问题不在于解析器是用C语言编写的,问题可能在于(我猜?)你没有提供方便的Python绑定。否则很难相信有人会放弃已完成的代码。我曾经也遇到过类似的情况,我通过JNI绑定到外部Java代码,并且运行良好。但他们最终还是通过HTTP调用重新编写了代码。当然这样做更简单,但我觉得……连作为备选方案或基准测试都不保留,这似乎有点太懒了。但我还是理解他们为什么这么做,因为这可能是潜在的错误来源,他们不想处理它。我也不想继承Prolog代码,因为它是一种古老的小众语言,IDE、文档和其他一切可能都很糟糕。

            1. 将HaXe移植到AS3并不能解决服务器组件的C++部分。

              即使我不需要C++部分,HaXe的实现方式与Flex差异巨大。此外,生成SWF文件的代码需要大量线性代数运算,这让原始开发者望而却步(代码必须提取显示对象的各种属性并将其转换为仿射变换矩阵,因为SWF格式就是通过这种方式原生编码的)。

              即使经过所有这些优化,代码仍会失去部分因使用HaXe编写而获得的性能提升。HaXe在MXMLC的基础上进行了两项重大改进:由于它能够在编译时证明类型正确性,因此消除了运行时类型检查(这几乎相当于去掉了半数字节码)。此外,如果能在编译时解析引用,它会生成更高效的查找代码(即MXMLC生成的代码会将所有上下文堆叠在函数可见范围内,每次引用非局部变量时都会调用函数遍历此堆栈;这尤其会惩罚包含嵌套函数的代码)。

              > 同样,我怀疑解析器用C语言编写并不是问题

              尽管你怀疑,但这就是问题所在……既然你从未参与过,从未与相关人员互动,也从未见过相关程序,为什么你要质疑我的判断?你凭什么认为自己知道得更多?

              > 问题可能在于(我猜?)你没有提供方便的 Python 绑定

              你猜错了……

              > 否则很难相信有人会放弃已完成的代码。

              你一定是新来的……我见过这种情况无数次。有时这甚至是一件好事。

              > 似乎有点太懒了。

              懒惰和无知才是游戏的本质。这是普通人的本性,但对于程序员来说,他们缺乏其他专业人士用来抵制懒惰和无知的机制。

              > 不想处理它。

              因为,让我重复一遍:无知和懒惰。

              > 我也不想继承Prolog代码,因为它是一种古老的小众语言,IDE和文档等一切都可能很糟糕。

              你显然也是无知和懒惰的:你没有去查证,就胡乱发表了一通无稽之谈。

              * 语言创建的年份有什么区别?英语比世界语更古老,这是否意味着英语更差?希伯来语比英语更古老,希伯来语更差吗?

              * Prolog 依然活跃。过去一年中,多个语言实现发布了新版本。许多学术研究在 Prolog 中进行,因为从概念上讲,它比 Java 或 Python 更先进。

              * 文档没问题。例如,Python 的文档要差得多,因为它们是由白痴写的……Python 文档的撰写者根本不知道如何做好,所以无论有多少人参与,或投入多少努力——最终产出的都是垃圾。

              * 我在使用Prolog编程时没有遇到技术问题。安装了SWI Prolog,编写了一些代码,运行它,修复错误,再次运行……一切正常。编辑器支持不是问题,与Python或其他更流行语言的编辑器支持相比,也没有什么不同。

              1. 从你的回复中,我猜我能明白为什么你总是遇到这些问题。虽然表面上你明白人们不愿做额外的工作,但你并不真正理解其中的原因,甚至不明白他们对Prolog、Haxe或C等语言的具体问题所在。围绕流行度、IDE支持、性能、生态系统、招聘、维护负担、安全问题等复杂的相互关联原因,对那些进行过最低限度的研究以理解现代语言真正需求的人来说,这些问题是显而易见的。“老旧”或“现代”这些术语显然应被视为一个笼统的术语,让有经验的人立即联想到整个生态系统相关的事物,例如包管理、IDE或库等,在这种情况下,Prolog的这些方面已经停滞不前。关于IDE,你甚至没有提到调试器,听起来你只是下载并使用了Swi Prolog的命令行界面,并且对此感到满意。其他人无法接受这一点,因为他们实际上需要对这段代码进行开发、维护和扩展,而他们可以轻松地使用现有语言完成这些任务,却无法使用你的语言。他们不希望仅仅因为你能够用另一种语言在记事本中编写一个控制台程序,就不得不重新学习一个在数百甚至数千个方面都存在问题的生态系统。显然,当今的技术标准不再是使用原始文本编辑器或像80年代那样使用Emacs进行printf调试。再加上你倾向于迅速将他人视为懒惰和无知,这表明他们甚至无法依赖你来解决未来的问题,因为谁知道会出现什么样的问题,阻止你解决问题。如果你真的不理解Python和Prolog在编辑器支持上的巨大差异,那么你才是那个懒惰的人,改变现状的责任在你身上。我并非因为匆匆浏览SWI Prolog官网和简单搜索后就立即否定Prolog而懒惰,因为我有足够的经验来综合考量所有这些因素。仅需一瞥SWI-Prolog官网、Prolog语法以及缺乏Visual Studio或IntelliJ支持的现状,便足以判断这种语言不会再具有相关性。当我指出同样的问题时,Ada实际上大幅改善了他们的网站和文档,这让我感到惊讶,因为大多数人并不在意,他们只是继续走错路。不过Ada仍然没有一个好的IDE,而他们的IDE比Prolog似乎拥有的要好得多。

          4. > 问题是我用C语言编写了解析器。这正是实现良好性能的关键。当我的经理回到工作岗位时,她意识到自己曾声明不了解C语言且永远不会学习(尽管她与该项目并无直接关联),结果项目被彻底放弃。

            我理解你的经理的处境。如果我手下有人在我不在岗期间,未经我同意就用C语言编写了一个组件,而他表面上是在做一个Python应用,我也会很生气。

            你选择了一种团队其他成员都不懂的编程语言,因此除了你之外,没有人能维护、调试或扩展你所编写的代码。而且你是在经理不在时偷偷进行的。你确实该承受这份怒火。

            有很多其他方法可以让性能接近C语言水平,同时保持Python开发团队能够维护的范围,例如使用Cython,甚至使用PyPy运行Python程序(尤其是涉及大量循环和基本字符串操作时)。

            你是否对Python实现进行了基准测试?C版本真的快多少?它一开始就是系统中的瓶颈吗?你在这部分组件的C版本上花费了多少开发人员工时,而如果使用等效的Python版本又会花费多少?

            此外,C语言是一门非常难学且难以有效使用的语言,因为它具有松散的类型系统和缺乏内存安全机制。你的决策给组织其他部分带来了巨大负担,而这可能并不值得为系统中这一部分组件的性能提升付出代价。

            我认为这是你做出的一个糟糕决定,因为它过于专注于实现一个狭窄的技术目标,而忽视了各种短期和长期成本。至少,你的决定与团队长期持续交付价值的更广泛目标并不明显一致。有无数案例可以证明,过于热衷的初级和中级工程师做出此类决定,最终给组织带来不良后果。

            如果另一支团队确实承诺使用不同的编程语言,然后又放弃了这一承诺,就像Haxe的例子那样,那这是他们的问题,与你无关。但如果你像个牛仔一样,用其他人团队成员都不熟悉的难以学习的语言编写代码,而且没有组织层面的支持来培训团队成员掌握这些语言,这绝不是一个好习惯。请注意,你才是这些不同组织中所有问题情况的共同因素。

          5. 这是略微不同的情景。如果你的产品是用C语言编写的,而你的老板对它无法饱和40Gbps链路感到失望,那么一个编写良好且注释清晰的C语言实现(能够做到这一点)很可能不会被拒绝。

            正如其他人指出的,你的许多失败听起来更像是政治问题而非技术问题。与白痴打交道并跳过政治障碍,让他们接受一个他们自己没有想出的想法,可能会非常疲惫。我本人在这方面没有特别的专长,也想不出任何绕过它的办法(当然,除了独自工作)。我认为合作是进步的代价。

      2. > 如果你试图编写一个速度快20倍的版本,你的同事会认为你在过度复杂化问题,而不是一个团队玩家。

        说得好!

        为什么会这样?

        1. 因为花费数十个开发者小时来节省数十个计算小时通常不值得。如果你能证明这值得投入时间、维护负担和失败风险,我就让你去做。

        2. 因为90%的开发者本就无法正确实现(即使他们自认为做到了),而正确的做法是依赖已有的库和服务来完成这些功能。

      3. 是时候换个工作环境了,并非所有人都天生爱计较或能力不足。

        1. 我的经验是,即使是最投入和聪明的人,作为团队也会陷入产生看似不必要复杂性的模式,因为这就是我们行业趋势和工具推动我们的方向。人们创建盒子来隔离感知到的复杂性,这反过来又会产生新的复杂性。

          我分享其他评论者的挫败感。我想摆脱这个泥潭。

          1. 我的经历恰恰相反——当我看到胡说八道时,我会直截了当地指出。所谓的“行业标准”胡说八道依然是胡说八道。

            事实是,许多开发者只是为了简历而工作,或者他们怀念童年玩玩具的日子。总体而言,软件开发者/工程师(我们)是一个极度被宠坏的群体,常常与现实脱节——这一点显而易见。

      4. 这种情况确实存在。每周/每天/每小时节省15毫秒,与整个生命周期中开发人员维护时间的差异,仍可能是个问题。

  7. 有没有好的数据处理库,能够对管道、套接字、文件和内存进行抽象,并实现此类优化?我很好奇 C、C++、Rust 或其他系统语言中是否存在此类库。

    我对文章中提到的某些API(如splice()和vmsplice())不太熟悉,因此想知道是否存在可在构建~低级应用程序时自动利用这些及相关优化的库。(正如另一位评论者所提:这些API难以使用,且大多数程序并未利用它们)

    像 libuv、tokio、Netty 这样的库在 Linux 上是否会自动处理这些操作?(根据一些初步研究,似乎它们确实会)

    1. 这可能与主流观点相悖,但由于这并非可移植的特性,因此不值得进行抽象化处理。你可能需要在需要的地方手动实现它。

      高级代码很少使用它们,因为它们非常专用于特定目的,并且必须针对 Linux 进行优化。如果你在 Linux 上传输数据而无需查看数据内容,splice 是有用的。具有这种特性的应用程序并不多(例如,TCP/UDP 代理肯定需要它——但普通的 HTTP 服务器?并不需要)。

      如果你在编写这类应用程序,那么“零拷贝”这类术语会频繁出现,而 splice 就是你首先会看到的结果之一。

      1. 人们之所以在这种情况下编写抽象层,主要是为了实现可移植性。我相信每个相关操作系统上都存在类似于vmsplice的实现。如果目标平台过于古老,库也可以回退到write_read函数。

        1. > 我相信每个相关操作系统上都存在类似于vmsplice的实现。

          并不存在。

      2. 我认为Linus通常认为splice是一个失败的实验。它在一些简单场景下工作良好,但实现其通用支持以确保其正常运行的努力未能实现。

        不过,如今sendfile是通过splice实现的,因此从某种意义上说,许多HTTP服务器都在使用它。

    2. 你可能想看看 Cosh[1].实际上,我正在研究这篇论文!这是一个提供消息传递抽象的模型,同时仍允许进行优化。我认为它在研究领域之外并不为人所知,而编写一个高效的 Cosh 实现可能需要一些时间。

      简而言之,它提供了三种传输模式:移动、共享和复制。例如,移动传输会将发送方具有读写权限的数据完全“传递”给接收方。这可能通过页面表虚拟内存重映射实现。它还具有强或弱属性,用于指示发送方和接收方是否可以信任合作,还是必须通过虚拟内存权限重映射严格限制。

      说实话,我不确定它是否能优化到足以可靠地匹配超优化管道或其他类似方案。这可能涉及“足够智能的编译器”问题。不过,我认为值得一试。

      [1] https://barrelfish.org/publications/trios14-baumann-cosh.pdf

  8. 精彩的文章,尽管管道对我来说是使用了二十五年的基本工具,但我还是学到了很多。

    1. 不难理解,你创建的管道并未传输你输出的任何数据。

          (echo red; echo green 1>&2) | echo blue
      

      这会创建两个由管道符号 | 分隔的子 shell。子 shell 是当前 shell 的子进程,因此它继承了当前 shell 的重要属性,尤其是打开的文件描述符表。

      由于它们是子进程,两个子 shell 会并发运行,而父 shell 则会等待所有子进程终止。子进程的运行顺序在很大程度上是不可预测的,在多核系统上,它们可能实际上同时运行。

      在子shell处理实际任务之前,必须先进行文件重定向。左侧子shell的标准输出(stdout)被重定向到由管道符号“创建”的内核管道对象的写入端。同样,右侧子shell的标准输入(stdin)被重定向到管道对象的读取端。

      第一个子shell包含两个依次运行的进程(红色和绿色,用分号分隔)。“红”确实被打印到标准输出,因此(由于重定向)被发送到管道。然而,管道中从未读取过任何内容:唯一连接到管道读取端(“echo blue”)的进程从未读取过任何内容,它仅用于输出。

      与“echo red”不同,“echo green >&2”的标准输出未连接到管道。其标准输出被重定向到与标准错误连接的对象。以下是“>&2”(或等效的“1>&2”)的含义:在执行“echo green”时,使标准输出(1)指向与标准错误(2)指向的同一对象。你可以将其想象为一个简单的赋值操作:fd[1] = fd[2]

      对于“echo blue”,标准输出并未显式重定向,因此它将以从父级 shell 继承的标准输出设置运行,而父级 shell 可能是你的终端。

      由于“echo green”和“echo blue”都直接写入同一个文件(很可能是你的终端),因此这里存在一个竞争条件——谁获胜基本上取决于谁先被调度执行。出于某种原因,似乎在你的系统上 blue 更可能获胜。这可能是因为左侧子 shell 需要先完成 “echo red”,而 “echo red” 会将内容打印到管道中,这可能会引入延迟或 yield,等等。

      1. 我认为你的消息(或其他消息)未能充分体现原博客文章的精髓。

        是的,管道会并行执行两个子命令,但这并非博客文章有趣(或其作者感到惊讶)的原因。是因为“echo red”本应阻塞,从而在管道的两个分支之间引入同步,但它并没有!

        我必须承认,当我阅读这条命令时,我的第一反应是: “好吧,第一个 echo 会因 SIGPIPE 信号而终止,而 stderr 输出将全是关于管道断开的信息。”但我错了,因为那个小缓冲区。

        我好奇其他 Unix 系统是否允许对断开的管道进行写入并成功完成?

        1. > 这是因为 ‘echo red’ 本应阻塞

          它实际上并不应该阻塞。管道在满载时会阻塞,但这里没有足够的数据来填满管道缓冲区。当管道断开时,会向写入方发送SIGPIPE信号。管道不会仅仅因为没有人从读取端读取而阻塞——只要读取端在某个地方仍然打开,一个进程可能从它读取,这就足够了。

          当你看到“blue”时,发生的情况是管道的左侧被杀死,因为右侧在“echo red”之前已经完成,这完全关闭了读取端,然后“echo red”被SIGPIPE信号杀死。这会带走“echo green”,因为“echo”是内置命令,因此“echo”不是子进程。如果你使用“/bin/echo red”代替,那么“green”将始终被打印(因为SIGPIPE信号会传递给/bin/echo,而不是整个shell)。

          在其他情况下,“echo blue”永远不会读取标准输入,但内核并不关心这一点。从内核的角度来看,“echo blue”可能会从标准输入读取数据,只要标准输入处于打开状态。

        2. 是的,我是在完成评论后才注意到这一点的(奇怪的是,这是我获得最多赞同的评论)。我一直以为这个命令是初学者试图理解 shell 时构建的,所以匆匆浏览了博客文章。

          但确实,作者并未意识到管道的读写端并非完全同步,因为中间的缓冲区允许一定程度的并发。我的说明对此并未明确提及(至少未明确指出写入管道时管道已满会导致阻塞),但我认为从技术上讲是准确的,希望这能澄清一些困惑——很多读者可能并不清楚 shell 的工作原理。

        3. 管道并未断开,至少在第二个echo进程终止前不会断开。内核并不知道echo永远不会读取标准输入,因为echo通常是一个非常简单的程序,不会主动关闭未使用的文件描述符。相反,管道在没有打开的接收端时才会断开,即当最右边的 echo 进程终止时。在此之前,它与任何其他管道没有区别

      2. 感谢您花时间撰写这篇详细且清晰的解释。

        1. 为了进一步澄清,echo 不会从标准输入读取,因此 … | echo xyz 不会做你可能假设的事情。尝试运行 echo a | echo b,你会发现只有“b”被打印出来。这是因为 echo b 不会读取通过标准输入发送给它的“a”(也不会打印它)。

          如果你希望程序从标准输入读取并写入标准输出,可以使用 `cat`,例如 `echo a | cat` 将打印“a”。

          最后,请注意 `echo` 通常是类似于 `print` 的 shell 内置命令。我不确定它可能以何种方式表现不同,但这是一个需要注意的点(它不是像 `cat` 这样的子进程)。

          1. shell 内置命令在此处的不同行为在于,当 `echo` 是内置命令时,SIGPIPE 信号可能会导致左侧的整个 shell 终止。

            当你执行 /bin/echo red 时,它是一个子进程,其父进程 shell 继续运行,因此输出中总会出现绿色。

      3. 简而言之,管道命令是并行执行的,而不是串行执行的。

        (数据是串行传输的。)

    2. 这可能令人意外,但若深入思考,便不难理解。管道中的程序会并行执行。若非如此,管道便毫无用处。例如,一个使用 curl 下载 tar 文件并解压的管道。若在运行 tar 之前等待 curl 完成,将会遇到各种问题。例如,如果 tar 文件非常大,你该把中间的 tar 文件存放在哪里?tar 需要在 curl 运行时同时运行,以保持缓冲区较小并加快执行速度。管道程序之间的唯一控制流是通过标准输入(stdin)和标准输出(stdout)实现的。在你的示例程序中,你写入标准错误(stderr),因此这自然不属于确定性控制流的一部分。

      1. > 如果没有,管道就不会有用。

        管道仍然是结构化程序的有用方式。它们只是会少一些用处。

      2. PowerShell 以确定性且无并发的方式实现管道,你可以非常精确地控制它。当然,如果你在管道中包含二进制文件,它会使用操作系统管道。

        Nushell 看起来似乎也有管道的内部实现。但我无法阅读 Rust,所以这只是我的猜测。

        1. “没有并发性”是什么意思?一个程序完全运行完毕后,另一个程序才开始运行吗?

          1. Powershell 管道是一个引擎构造,而不是操作系统管道或文件描述符。(如果你在 PS 管道中包含操作系统二进制文件,它会将内部管道映射到操作系统管道,当然。)

            每个 PowerShell 命令都有一个开始、处理和结束块。(如果你没有显式编写这些块,你的代码会进入一个隐式结束块。)

            当管道被评估时:

            1. 从左到右,每个命令的开始块依次运行。在每个开始块都执行完毕之前,不会执行任何过程块或结束块。

            2. 每个命令的过程块会根据管道传入的对象数量分别执行一次。过程块可以输出零个、一个或多个对象;我需要在计算机上确认,但据我所知这是“广度优先”——过程块输出的每个对象都会传递给下一个过程块,然后再将控制权返回给当前过程块。

            3. 在所有过程块从左到右执行完毕后,每个命令的结束块将被执行。未声明过程块的命令将接收所有管道对象作为单一集合。结束块的任何输出都会触发右侧的过程块。

            4. 当所有结束块完成后,管道将停止。

            5. PowerShell 中的错误可分为终止型和非终止型。当抛出终止型错误时,管道将停止。

            6. 存在一个特殊的 StopPipeline 错误,该错误会停止管道但由引擎处理,因此用户无法看到。这就是 `select -First 5` 的工作原理(针对 PS `select`,而非 gnu select)。

            管道仅在流 0 和 1 上运行,与操作系统管道类似。其他流(PowerShell 有 7 个)会立即处理,但出于性能考虑,部分缓冲行为会被优化。总体而言,替代流由默认设置和每个命令的开关单独控制,由引擎渲染并传递给控制台显示。但它们也可以重定向或捕获到变量中。

            Powershell支持异步操作;多线程通过名为“runspaces”的构造实现。这些构造与管道本身无关,但管道中的命令可以实现它们,例如foreach -Parallel {do-stuff}

            1. 好吧,听起来Powershell会遇到与Linux管道完全相同的问题。该问题与管道构造的确定性无关,而完全在于管道的一部分写入 stderr(可称为流 2)。

              1. echo green 写入 stderr 的主要意义在于,您可以观察到非确定性行为,因为如果它写入 stdout,其输出将不可见。

                不确定性的主要部分在于 echo red 是否成功或失败,以及程序退出顺序。即使你只运行 “echo red | echo blue”,这也可能是不可预测的。但在这种情况下,你总是会看到 “blue”,因此很难察觉。

                在 PowerShell 中,这将是确定性的。听起来 echo red 总是会成功。

    3. 这有什么好惊讶的?你觉得输出结果会是什么样子,为什么?也许这些信息能帮助澄清任何困惑。

      这条命令,或许是故意为之,看起来有些异常(任何代码审查者都会感到困惑):

      其中有一个“echo red”,但它从未被发送到任何地方(或许是个关于“红鲱鱼”的玩笑?)。

      有一个“echo green”被发送到stderr,只有在“echo blue”之前终止时才会可见。

      确切的顺序将取决于输出缓冲,这将取决于哪个时间片段首先被排序,这将随着CPU数量及其各自负载而变化。因此,是的,它将是不可预测的,但与“top”一样。

    4. 这种情况会导致实际问题吗?因为坦白说,这个例子似乎有些人为。

    5. 我真的很想知道,还有其他什么方式能让它工作?这就像创建线程,本质上就是不可预测的。

      1. 如果我尝试将管道连接到不接受管道输入的命令,我的 shell 会抛出错误。这是更好的设计。

        这也是为什么 Python 糟糕的原因——如果你给它垃圾输入,错误可能在很远的地方浮现,而且在水下时可能会造成很大破坏

    6. ChatGPT 通过简单的“这段代码做什么”就能搞清楚。但也有可能是 ChatGPT 基于你的文章进行了训练。

      >>> 注意:输出中“绿色”和“蓝色”的顺序可能因 shell 或操作系统对这些流(标准输出和标准错误)的缓冲方式不同而有所变化。通常情况下,你会看到如上所示的输出。

      1. 不过这不对,这与不同的缓冲无关(顺便说一句,缓冲通常在应用程序级别进行)。

  9. 简而言之:假设两个程序都以最优方式编写,管道的最大速度大约等于系统中单个核心的读写速度;这是因为,本质上,内核将一个程序的 stdout 映射到另一个程序的 stdin,从而使操作成为零拷贝(或在稍逊一筹的情况下成为快速一次拷贝)。

    我早就知道这一点,这使得编写使用管道将两个(或更多)程序连接起来以执行极高性能操作的 shell 脚本既令人满意又令人发笑。无疑是工具箱中最有用的一项工具。

    1. 管道仅在使用 splice 或 vmsplice 时才是零拷贝。这些 Linux 特定的系统调用难以使用(特别是 wmsplice),而且绝大多数程序和 shell 过滤器(值得注意的例外是 pv)都不使用它们,因此需要承担从内核内存中读写数据的开销。

      1. 如果你使用 Go,它会在使用 io.Copy 等时自动将读取器/写入器进行拼接

    2. 据我所知,管道的一个严重限制是它们只能缓冲64KB/16页(在x86 Linux上)。我敢肯定,这通常比内核到内存的带宽更慢。

      1. 64KB 是默认值,你可以使用 fcntl 增加缓冲区大小。你可能更受系统调用开销的限制,而不是其他因素

    3. 这就是为什么线程并没有许多程序员想象中那么重要。很可能,你正在构建的任何应用程序都可以通过管道 + 进程或绿色/用户空间线程以更干净的方式实现,具体取决于工作负载。虽然可能不太“方便”,但消息传递通常比死锁地狱更可取。

      1. 管道是内核中实现的 FIFO 数据缓冲区。对于同一进程中线程之间的通信,您可以使用受互斥锁 + 条件变量保护的用户空间队列实现来替换任何管道对象。这在功能上等同且可能更快。若将所有访问操作包裹在锁定/解锁对中(中间不锁定其他对象),则与使用内核管道相比,不会引入更多死锁风险。

        线程是重要的结构化机制:您可以假设所有线程持续运行,或在崩溃时所有线程终止。

        此外,单向管道并不完全适用于进程间/线程间同步。它们适用于简单的批处理,但仅此而已。

        1. 顺便说一句,你可以使用完全相同的设置(加上mmap)来实现进程间队列。

          线程的优势在于,你可以通过队列传递数据指针,而进程间实现这一点则较为困难,你不得不 resort to 在队列中复制数据。

          1. >而进程间实现这一点则较为困难,你不得不 resort to 在队列中复制数据。

            我可能错了——我从未尝试过,但据我所知,甚至可以将POSIX互斥锁和条件变量存储在共享内存中,这样两个进程(或更多?)就可以在不复制数据的情况下处理数据,只要它们都使用存储在共享内存中的相同锁。

            1. 是的,当互斥锁或条件变量初始化时使用属性 PTHREAD_PROCESS_SHARED。

        2. 如果你需要传递文件描述符等功能,可以使用域套接字。管道和套接字(包括TCP,但有明显限制)在正确设置标志的情况下可以实现零拷贝,不过如果涉及复杂的运行时环境(如垃圾回收),事情会变得更复杂。始终可以显式映射共享页面

      2. > 这就是为什么线程并没有许多程序员想象中那么重要。很可能,你正在构建的任何应用程序都可以通过管道+进程或绿色/用户空间线程以更干净的方式实现,具体取决于工作负载。

        我认为你是在基于一个过于概括的稻草人论点(即“线程的重要性远不如许多程序员所认为的那样”)做出夸张的断言,随后又试图用模棱两可的措辞(“取决于具体的工作负载”)来淡化这一论点。

        线程被广泛使用是因为它们带来了进程的大部分优势(并发控制流,以及在多核处理器上也带来性能优势),同时避免了进程带来的限制和局限性(独占内存空间、创建速度慢、由于进程间通信中的串行化导致的性能开销、笨拙的API等)。

        在多线程应用中,要让线程之间进行通信,你只需要指向你实例化对象的内存地址即可。无需序列化,无需任何其他操作。在“干净的方式”上,你无法超越这一点。

        > 它可能不太方便,但 (…)

        这简直是委婉的说法,而且忽视了为什么线程被广泛偏好的原因。

        1. > 之后你试图用模棱两可的措辞淡化(“取决于具体的工作负载”)

          我只是说,选择多进程消息传递还是用户空间/绿色线程取决于工作负载,而不是淡化我的主张,尽管这个陈述确实有例外(见下文)。

          > 没有它们带来的限制和局限性(专用内存空间、创建速度慢、IPC中的串行化导致的性能开销、笨拙的API等)。

          这在几乎所有类UNIX系统中都不成立,但在原生Windows系统中某种程度上成立。线程是进程,它们在*nix系统中与进程以相同的方式创建、调度和终止。你只需在`fork()`函数中添加一个标志,告诉它为新创建的进程提供线程语义(即共享内存),就这样。还有一些关于信号屏蔽和其他重要事项的隐式处理,这些在线程中会得到更合理的默认设置,但大致就是这样。在进程之间高效共享数据的方法有很多,甚至不需要复制。如果你真的不想使用管道或套接字,可以映射共享内存页,但后者可以实现零拷贝和零序列化。当然,这些原生 API 有些笨拙,但这并不妨碍语言使其变得更简洁。

          > 在多线程应用中,要让线程之间通信,你只需指向你实例化对象的内存地址即可。无需序列化,无需任何额外操作。在“干净”的实现方式上,这简直无可匹敌。

          我指的是,这种自由共享内存的方式会鼓励不良的应用程序设计,因为你无需区分共享内存和非共享内存,默认情况下所有内存都是共享的。

          对此的例外情况主要是Windows上的某些高性能应用程序,这意味着如今主要是视频游戏(当然有例外,但这是最明显的案例)。我认为这些是少数几个无法在不使用线程的情况下达到目标的场景。

          无论如何,我主要是从编程语言设计的角度来看待这个问题,而非操作系统角度。线程确实是一种有用的抽象,但更多是出于便利性。

          无论如何,以下是一些支持我观点的冷硬数据:

          – 全球最流行的两种语言,JavaScript 和 Python,都采用单线程运行时,并通过绿线程/异步-等待实现并发(只需搜索一下,这并不争议) – 最流行的关系型数据库管理系统(RDBMS)PostgreSQL,以及最流行的Web服务器nginx[0],均不使用线程,却仍能实现高性能和高灵活性- 如今,扩展通常通过网络进行水平扩展,这与消息传递架构非常契合

          [0]: https://w3techs.com/technologies/overview/web_server

      3. 消息传递足够多,你很容易就会陷入死锁。

      4. 就像Postfix的工作方式。这是一种有趣的架构。多个进程和基于文件的队列。与此同时,如果没有PostgreSQL来保存我的数据,我会感到恐慌 :/

        1. Postgres不使用线程,它是一种多进程架构。Postfix可能故意这样设计,以防止在系统崩溃或断电时丢失外发(或通过POP3接收的)邮件。

      5. 管道的问题在于,传递消息会涉及内核上下文切换,无论消息大小如何。

        在进程内传递消息的速度比在进程间传递消息快几个数量级。

    4. > 使用管道将两个(或多个)组件连接起来编写 shell 脚本以实现极高性能操作,既令人满足又令人发笑

      发笑是因为人们/团队花费数周时间和大量资金却只能获得较差的结果?

    5. 这是我无法理解的魔法系统内容,它是否必须一直传输到内存,还是缓存会让我们免于这次传输?

      1. 这完全取决于 CPU 架构。

        我能给出的最简单答案是:是的,当安全时;当不安全时,这就是熔断/幽灵家族漏洞的一部分。

    6. 我假设对于异构核心(性能核心与效率核心),性能瓶颈会出现在最慢核心的吞吐量上?

      1. 令人惊讶的是,并非如此。我预计性能会相似。

        在这些设计中,实际与内存通信的内存控制器是内部互联结构的一部分,而核心与内存控制器之间的互联链路(从技术上讲)就是你的性能上限。

        对于英特尔和AMD而言,互联链路的大小与不同核心的预期性能保持一致,因为加载/存储单元的理论使用率/性能在两者之间保持恒定,无论核心是大还是小。

        此外,请注意:负载/存储单元的最大性能才是实际的上限,点到为止。历史上有些CPU从未达到其最大理论性能,因为这些单元从未被优化利用;有时这是因为负载/存储单元的某些端口仅能通过特定指令访问(通常因被保留用于SIMD;这就是为什么memcpy实现常使用SSE/AVX,仅为利用这一特性)。

        不过,加载/存储性能通常会接近该核心的L2理论最大值,而这一值通常高于核心通过其 fabric 链接能达到的性能。因此,fabric 链接在这种情况下往往是性能瓶颈。

        在英特尔和AMD的集群中,为各自核心集群设计提供服务的内存控制器需要2到4个核心饱和其链接才能达到峰值性能。此外,同一核心上的兄弟线程会竞争访问该链接,因此不仅仅是线程数量决定性能,而是实际核心饱和度。

        在类似链接文章中提到的模拟基准测试中,单个进程被管道传输到另一个进程时,无论是“两个进程实际上位于同一大核心上,同时进行超线程”,还是“同一核心集群中的两个兄弟小核心,由同一内存控制器服务”, 性能上限应接近内存带宽的优化使用,但在某些架构上,这实际上会接近L3带宽(更高值)。

        此外,值得注意的是:小核心并非真正意义上的“小”。在硅片使用量略微增加、功耗略微降低的情况下,两个小核心的性能可接近一个大核心(带有两个线程)的优化执行效果,即使在英特尔令人惊讶的优化小核心设计中也是如此,但在Zen4c架构中更是如此。也就是说,我可以买到一款“哎呀,全是小核心”的CPU,其尺寸足以满足我的桌面需求,而且我仍然会感到满意(甚至可能更满意)。

  10. 这篇文章讨论了如何加快Linux管道的速度,但其他方法如共享内存或消息队列可能更快。例如,在需要快速传输大量数据的系统中,管道的额外步骤可能会拖慢速度。此外,当多个线程共享数据时,管道可能会比其他方法引发更多问题。因此,文章中的改进在实际应用中可能帮助不大,尤其是在速度至关重要的场景下。

    1. 你能举些例子吗?在批量处理数据时,选择像 io_uring 这样的方法确实有优势。但对于双向通信,你仍然需要在数据准备就绪时通知双方(也许你不想通过轮询消耗 CPU),而我还不清楚这些选项如何比管道更快速地处理这种同步。

      1. io_uring的主要优势在于避免多次系统调用。

        使用管道时,你无法避免这一点。使用共享内存队列/环形缓冲区时,你可以直接写入内存而无需系统调用。

        但你需要自行实现同步机制(例如使用信号量)。你不必一定使用轮询。

    2. 使用消息队列库的另一个好处是,你不必过多担心跨平台兼容性问题。

  11. 绝对令人惊叹,我了解页表等概念,但将其与性能分析工具`perf`结合使用,能清晰地说明其对吞吐量的核心作用。

  12. 管道速度足够快,可以用于迭代和组合使用cat、sed、awk、cut、grep、uniq、jq等工具。

  13. 哈哈,不错的文章 🙂 我记得曾经为Cygwin管道实现的性能而苦恼。与Linux相比,它们慢得多,但仍然可用,只是在数据输入/输出时有些棘手。

  14. 你可能通过避免使用 libc 并直接调用系统调用(syscalls)来进一步提升速度。从最终的性能输出来看,使用 libc 函数似乎存在一些开销

  15. 为什么不直接在程序管理的共享内存环形缓冲区中谨慎使用 mmap?这样就可以以接近内存速度进行复制。

    1. 这篇帖子主要是为了解释虚拟内存概念,而非教程,这一点我可能应该说明得更清楚。

  16. 与原始内存吞吐量相比,它们的速度如何?

    有趣的是内存映射成本如此之高。我经常好奇大家为多地址空间付出的代价。隔离真的值得吗?

    1. 虚拟内存的相对性能开销在过去要高得多,但人们认为为了提高系统可靠性,这值得付出代价。

  17. 喜欢这个网站的Edward Tuftian美学风格。不过当视口宽度超过一定阈值时,我认为需要添加margin: 0 auto来居中内容块。在27英寸显示器上,不调整窗口大小的话阅读起来很吃力。

    1. 我完全同意……我非常喜欢侧边注释以获取更多细节/解释。你可以跳过侧边注释继续阅读并保持在主故事中,但仍能获得通常会以括号或内联注释形式呈现的内容……我认为这兼顾了两全其美。如果我积极维护一个博客,我可能会借鉴这个设计! 🙂

      1. 如果查看宽度过小,有没有标准的CSS/HTML方法将侧边注释或图片推到第一列?

        那将两全其美!

        1. 响应式设计概念可以实现这一点

  18. 有人注意到隐藏在第一个表格后面的股票图片吗?

    我只能通过深色模式扩展看到它,否则我保证不会注意到它。

  19. 我记得大约12年前使用Linux管道来运行一个基于shell的IRC客户端。对于大多数应用程序来说,它们的速度已经足够快了。真希望我还能找到那个源代码。

  20. 这是一篇写得很好的文章,解释得非常清楚,我非常喜欢。

    然而,所有使用vmsplice的变体(即除了最慢的那个之外)都不安全。当你将[1]个页面赠予内核时,没有可靠的通用方法来确定这些页面何时可以安全地重新使用。

    这篇帖子(以及之前的FizzBuzz变体)试图通过假设在捐赠后写入“管道大小”字节后页面再次可用,来绕过这个问题,_但这在一般情况下并不成立_。例如,读取方也可能使用类似splice的调用,以零拷贝方式将页面移动到另一个管道或IO队列,因此页面的生命周期可能超出原始管道的范围。

    这将表现为竞争条件和数据的突然变化,下游消费者会看到页面突然被原始进程覆盖而发生变化。

    这些 splice 方法的作者 Jens Axboe 曾提出一种机制,可用于确定何时安全地重用页面,但据我所知该机制从未被合并。因此,此方法仅适用于您同时控制管道两端且能确切掌握页面生命周期的场景。

    [1] 具体而言,使用 SPLICE_F_GIFT。

    1. (我是该帖子的作者)

      我尚未完全理解这条评论,但为了明确起见,我 _不__ 使用 SPLICE_F_GIFT(我认为 FizzBuzz 程序也不使用)。然而,我认为你所说的在一般情况下是有道理的,无论是否使用 SPLICE_F_GIFT。

      你确定这种不安全性取决于 SPLICE_F_GIFT 吗?

      此外,你有关于此讨论的参考资料吗( presumable 在 LKML 上)?

      1. 是的,我提到“gift”只是个幌子:我原本以为“gift”会被使用,但同样的问题(“页面垃圾回收问题”)无论如何都会出现。

        如果你不使用“gift”,你就永远不知道页面何时可以再次使用,因此从理论上讲,你需要无限期地继续写入新缓冲区。解决此问题的“方案”是使用 gift,此时内核会为你进行垃圾回收,但你需要不断消耗新页面,因为你已经将旧页面释放了。gift 特别有用的是当释放的页面可以直接用于页面缓存(即写入文件,而非管道)时。

        在不使用“gift”的情况下,某些使用模式可能是安全的,但我认为这些模式恰恰涉及复制(不使用“gift”意味着在额外的读取场景中会发生复制)。最终的问题是,如果某个下游进程能够从上游写入者获取页面的零复制视图,那么如何确保并发修改的安全性?管道大小技巧是一种可能的实现方式,但它行不通,因为页面可能存在于立即管道之外(这实际上在 FizzBuzz 文章中有所提及,他们提到如果涉及多个管道,事情会出问题)。

        1. 是的,这一切都说得通,尽管像所有与拼接相关的内容一样,它非常微妙。也许我应该在开头就提到拼接的微妙性和危险性,而不是在结尾。

          我仍然认为vmsplice的手册页非常具有误导性!具体来说:

                 SPLICE_F_GIFT
                        用户页面是赠予内核的。应用程序不得修改
                        此内存,否则页面缓存和磁盘上的数据可能不一致。
                        将页面赠予内核意味着后续的 splice(2)
                        SPLICE_F_MOVE 操作可以成功移动页面;如果未指定此标志,
                        则后续的 splice(2) SPLICE_F_MOVE 必须复制页面。
                        数据必须在内存和长度上都正确对齐。
          

          对我来说,这表明如果我们不使用 SPLICE_F_GIFT,下游的拼接操作在安全性方面将自动得到处理。

          1. 嗯,将这段内容与 BeeOnRope 评论中的一段并排阅读:

            > 这篇帖子(以及之前的 FizzBuzz 变体)试图通过假设在 gift 之后写入 “pipe size” 字节后页面再次可用,来绕过这个问题,_但这在一般情况下并不成立_。例如,读取侧也可能使用类似拼接的调用,以零拷贝方式将页面移动到另一个管道或 IO 队列,从而使页面的生命周期超出原始管道。

            你引用的段落指出,当未指定 SPLICE_F_GIFT 时,“类似 splice 的调用移动页面”实际上会进行复制。因此,或许不使用 SPLICE_F_GIFT 并等待写入“管道大小”字节后再操作是安全的。

            1. 是的,我不清楚复制何时实际发生,但我曾假设读取后超过 30 GB/s 的结果在改用 splice 后必须意味着零拷贝。

              1. 可能是当将数据拼接至 /dev/null(我正在这样做)时,内核知道其内容从未被读取,因此无需进行复制。但我尚未验证这一点。

                1. 有道理。如果如此,vmsplice 在实际场景中的一些出色基准测试结果可能会消失,因此了解这一点会很有帮助。

                  1. 拼接似乎在管道进程链的中间部分工作良好,例如pv的工作方式:它可以将一个管道中的页面拼接到另一个管道中,而无需担心页面重用,因为上游已经写入了该页面。

                    同样,从管道到文件或其他类似场景的拼接也适用。真正引发问题的是链条的末端,即需要(a)在内存中生成数据或(b)从内存中读取数据的场景。

      2. 我认为你说的没错,即使没有 SPLICE_F_GIFT,同样的问题也会存在。另一位 fizzbuzz 代码高尔夫选手在这里讨论过这个问题:https://codegolf.stackexchange.com/a/239848

        我好奇io_uring是否已处理此问题(目前)。io_uring是同一作者开发的较新异步IO机制,可告知IO操作完成时间。因此你可能会认为它会:

        * 但从快速查看来看,我认为其 vmsplice 等效操作只是告诉你系统调用本应返回的时间,所以可能不行。[编辑:实际上,看起来最新的主线树中甚至还没有 IORING_OP_VMSPLICE 操作,只有 lkml 上的草稿。也许如果/当 vmsplice 操作被添加时,它会等到正确的时间再返回。]

        * 在这种情况下(等待期间没有其他系统调用或工作要执行),我看不出来 io_uring 的读写操作相较于普通的同步读写有什么优势。

        1. 或许可以在uring中通过对预先mmap的memfd使用splice操作来模拟实现?我好奇这种方式的速度如何,以及在安全性方面与现有方案的对比。

        2. uring主要适用于异步IO,它会告诉你一个本应阻塞的系统调用何时完成。由于此处的基准测试使用阻塞调用,行为不应发生变化。缓冲区的生命周期与操作的生命周期是相互独立的。即使内核知道操作在内核中何时完成,它也没有办法知道消费应用程序是否已经完成对它的使用。

          1. > uring 仅适用于异步 I/O,它会告诉你一个本应阻塞的系统调用何时完成。由于此处的基准测试使用了阻塞调用,因此行为不应发生变化。缓冲区的生命周期与操作的生命周期是相互独立的。即使内核知道操作在内核中何时完成,它也没有办法知道消费应用程序是否已经完成对它的使用。

            这与我读到的内容不符。例如,https://lwn.net/Articles/810414/开头写道:“从本质上讲,io_uring 是一种用于执行异步 I/O 的机制,但它已逐步超越这一用例并添加了新功能。”

            更准确地说:

            * 尽管目前大多数/所有操作都是异步 I/O,但是否有理由认为人们不会希望将其扩展到批处理几乎任何热路径非 vDSO 系统调用?如我所说,批处理在这里没有帮助,但在许多其他场景中是有帮助的。

            * 几个 IORING_OP_ 似乎正在扩展与同名系统调用不匹配的功能。例如,无需文件描述符的 I/O、注册缓冲区、自动缓冲区选择、多发操作,以及(截至一个月前)“环映射提供的缓冲区”。除了单个操作级别,还支持链式操作。为什么不引入一种机制,在传递给vmsplice的缓冲区可供重复使用时触发完成信号?(也许通过本质上延迟vmsplice系统调用的返回[1],也许通过第二个命令,也许通过同一命令的额外完成事件,具体细节待定。)

            [1] 编辑:虽然我猜这可能不太理想。读取方可能已移动页面并希望检查后续字节,但这些字节要等到写入方看到 vmsplice 返回并发出进一步写入操作后才会被写入。

            1. 没错,就是这样。

              vanilla io_uring 在异步模型中“自然”适用,但其提供的批处理和其他功能对同步模型中的操作也非常有用。

              此外,io_uring 有时甚至无需应用程序显式批处理即可避免系统调用,因为它可以轮询提交队列(仅限 root 用户,上次检查时不幸的是): 因此,通过适当的配置,使用 io_uring 进行的一系列“同步”操作(即提交并立即等待响应)可以实现每个操作少于 1 次用户-内核转换,因为内核正忙于直接处理来自输入队列的操作,而应用程序在等待之前通过轮询阶段获取响应。

      3. 实际上,重新阅读vmsplice的man手册后,似乎它_应该_依赖于SPLICE_F_GIFT(换句话说,没有它也应该安全)。

        但根据我对vmsplice实现方式的了解,无论是否使用gift,它似乎都应该是不安全的。

    2. > 然而,所有使用 vmsplice 的变体(即除最慢的变体外)均不安全。当向内核赠送 [1] 页面时,没有可靠的通用方法来确定这些页面何时可以安全地重新使用。[snip] 这将导致竞争条件和数据的突然变化,下游消费者会看到页面突然被原始进程覆盖而发生变化。

      这听起来像是安全问题——上游生成进程能够写入下游读取进程的内存,或者更糟糕的是反之亦然。我假设 Linux 内核仅在两个进程以同一用户身份运行时才允许这种情况发生(零拷贝)?

      1. 我不确定内核是否允许接收进程写入而非仅读取。

        此外,如果你在发送数据,为什么之后还要读取/处理那个发送缓冲区?

        我能想象到的唯一攻击向量是,如果一个发送者将同一内存段同时拼接给两个或多个接收者。一个具有写入权限的恶意接收者可以利用拼接后的内存来破坏其他读取者。

    3. 如果写入者完全释放内存,是否会导致读取者发生段错误?这将是一个非常危险的模式。

      1. 不,因为“释放”内存在于单个进程内是有意义的,实际上意味着“移除页面的 V->P 映射”,或者更准确地说,类似于“告知 malloc() 实现该指针(暗示一段虚拟内存范围)是空闲的,这可能导致该 V 范围从进程中解除映射(或执行类似 M_ADV_DONTNEED 的操作)”。

        这些操作均不会影响映射到其他进程中的同一物理页面及其独立的V->P映射。

  21. 我曾不得不调整对某些操作速度的认知模型。我曾将`seq`作为其他程序的输入,当时认为这是一个在CPU上高速运行的生成程序,因此会非常快速。具体来说,因为它只会将数据写入内存供下一个程序使用,而不会读取任何数据。

    但事实证明我完全错了,`seq` 实际上慢得离谱。我深入研究了一番,并制作了一个更快的 `seq` 版本,这基本上实现了我的需求。但后来发现,无论如何这都无关紧要,因为通过命令行将输出管道传输到下一个程序本身就是瓶颈,所以无论如何都无所谓。

    https://github.com/tverniquet/hseq

    1. 我曾经在使用 GNU parallel 时有过类似的发现。我试图从单台机器生成尽可能多的网络流量来对正在构建的服务进行负载测试,我原本以为网络 I/O 将会是瓶颈,而不是启动多个进程的开销。生成的流量远低于预期,于是我用 Ruby 重新实现了该功能,使用 parallel gem 并采用线程(而非进程)方式,性能提升了数个数量级。

  22. 在我的 Mac Studio 上运行了基本初始实现,令人惊喜地发现

      @elysium pipetest % pipetest | pv > /dev/null
       102GiB 0:00:13 [8.00GiB/s] 
    
      @elysium ~ % pv < /dev/zero > /dev/null
       143GiB 0:00:04 [36.4GiB/s]
    

    由于我不知道原始机器的配置,因此无法对两台机器进行有效的比较。但MacOS在这种类型的比较中通常表现不佳,而这里采用的简单方法给出了8 GB/s的速率,而非作者提到的3.5 GB/s,这比我预期的要好,即使考虑到我使用的机器配置。

      1. 这台机器是苹果公司生产的性能最强的Mac。

  23. 这是一篇冗长但极具洞见的文章!

    (顺便说一句,这种字体与手绘图表的组合真的很酷)

      1. 这是IBM Plex字体,他们使用了IBM Plex Mono、IBM Plex Serif和IBM Plex Sans的组合。

        以下是来源:https://www.ibm.com/plex/

        希望对您有帮助!

  24. 大部分开销(以及传输速度较慢)似乎都出现在使用管道的脚本/系统中。

    当我看到 ZFS 发送/接收使用管道时,我曾担心性能问题——但在实际使用中,我能够轻松达到 800MB/s 以上的传输速度。似乎是本地磁盘阵列的 I/O 性能限制了速度,而非管道性能的限制。

    1. 没错。我其实对256KB传输的测试结果感到意外,更希望测试时使用>1GB的传输量。对于如此小的传输量,启动进程和加载库的开销显然远超实际工作量。我也对这没有在性能剖析中显示出来感到惊讶。但显然这取决于测量起始和结束点的位置

      1. 也许我误解了你所指的内容,但文章中的测试是在测量传输10 GiB的速度。256 KiB只是缓冲区大小。

        1. 博客文章中的第一个C程序分配了一个256kB的缓冲区,并将其写入stdout一次。我没有看到另一个循环多次写入它。

  25. 出于某种原因,这让我好奇不同语言写入管道单个字符的速度:

    PHP 的速度约为 900KiB/s:

        php -r ‘while (1) echo 1;’ | pv > /dev/null
    

    Python 的速度约为 1.5MiB/s,比 PHP 快约 50%:

        python3 -c ‘while (1): print (1, end="")’ | pv > /dev/null
    

    JavaScript 最慢,约为 200KiB/s:

        node -e ‘while (1) process.stdout.write(“1”);’ | pv > /dev/null
    

    值得注意的是,Node.js 在大约一分钟后会崩溃:

        FATAL ERROR: Ineffective mark-compacts
        near heap limit Allocation failed -
        JavaScript heap out of memory
    

    所有结果均来自运行在 Debian 10 Docker 容器中的 PHP、Python 和 Node.js(使用默认仓库版本)。

    更新:

    通过 strace 检查发现 Python 会缓存输出:

        strace python3 -c ‘while (1): print (1, end="")’ | pv > /dev/null
    

    输出了一系列:

        write(1, “11111111111111111111111111111111”..., 8193) = 8193
    

    PHP 和 JS 则不会。

    因此,Python 的等效代码为:

        python3 -c ‘while (1): print (1, end="", flush=True)’ | pv > /dev/null
    

    这使得其速度与 JS 相当。

    有趣的是,PHP 的速度比 Python 和 JS 快了超过 4 倍。

    1. > JavaScript 的速度最慢,约为 200KiB/s:

      我使用该代码获得约 1.56MiB/s 的速度。PHP 获得 4.04MiB/s。Python 获得 4.35MiB/s。

      > 另一个有趣的现象是,Node.js 在运行约一分钟后会崩溃

      我认为这是因为 while(1) 循环运行得太快,导致 V8 没有足够的空闲时间来执行垃圾回收。V8 是一个复杂的引擎,这只是我的猜测。

      以下代码不应导致崩溃,你可以尝试一下:

          node -e ‘function write() {process.stdout.write(“1”); process.nextTick(write)} write()’ | pv > /dev/null
      

      不过对我来说速度较慢,仅达到 1.18MiB/s。

      更多使用 Babashka 和 Clojure 的示例:

          bb -e “(while true (print1“))” | pv > /dev/null
      

      513KiB/s

          clj -e “(while true (print1“))” | pv > /dev/null
      

      3.02MiB/s

          clj -e “(require '[clojure.java.io :refer [copy]]) (while true (copy1“ *out*))” | pv > /dev/null
      

      3.53MiB/s

          clj -e “(while true (.println System/out1“))” | pv > /dev/null
      

      5.06MiB/s

      版本:PHP 8.1.6,Python 3.10.4,NodeJS v18.3.0,Babashka v0.8.1,Clojure 1.11.1.1105

      1. >> 值得注意的是,Node 在大约一分钟后会崩溃

        > 我认为这是因为 while(1) 运行得太快,导致 V8 没有足够的“空闲”时间来执行垃圾回收。V8 是一个奇怪的引擎,这只是我的猜测。

        并非完全如此:GC仍在运行;问题在于活动内存正在无限增长。

        这里发生的情况是,WritableStream是非阻塞的;它具有建议性背压机制,但如果你忽略该机制,它仍会尽可能接受写入操作并将其保存在缓冲区中,直到能够实际写出这些数据。由于你没有给它任何缓冲空间,该缓冲区会持续增长直至内存耗尽。`process.nextTick()` 可能在你的系统上足够减慢速度,使其有机会清空缓冲区。(我看到下面有讨论提到这在不同版本中会变化;我猜这是其他优化措施的副产品。)

        要正确实现这一点,你需要监听 `.write()` 的返回值,如果返回 false,则暂停操作,直到流清空且缓冲区再次有空间。

        以下是我用来实现此功能的(未经过优化的)函数:

          async function writestream(chunks, stream) {
              for await (const chunk of chunks) {
                  if (!stream.write(chunk)) {
                      // 当 write 返回 null 时,流开始缓冲,我们需要等待其清空
                      // (否则将耗尽内存!)
                      await new Promise(resolve => stream.once(‘drain’, () => resolve()))
                  }
              }
          }
        

        我确实希望 Node 能更明确地说明这种情况下的工作原理;这是流操作中非常常见的错误,而且很容易在事情突然出错之前没有注意到。

        补充说明:我应该指出,转换流、readable.pipe()stream.pipeline() 等都自动处理这些情况。不过这里有一个一行代码的解决方案,虽然速度不算特别快:

          node -e ‘const {Readable} = require(“stream”); Readable.from(function*(){while(1) yield1”}()).pipe(process.stdout)’ | pv > /dev/null
        
        1. 是否仍然没有比旧的基于事件的机制更容易处理此问题的异步写入函数?等待排水也可能导致吞吐量降低,因为此时缓冲区中没有数据,对等方将被迫暂停读取。一个“可写入”事件似乎更合适——但节点文档中未提及此类事件。

      2. 您的 Node 版本确实没有崩溃。测试了 2 分钟。

        但使用更长的字符串后,23 秒后崩溃:

            node -e 'function write() {process.stdout.write(" 1111111111222222222233333333334444444444555555555566666666667777777777888888888899999999990000000000 "); process.nextTick(write)} write()' | pv > /dev/null
        
        1. 嗯,奇怪。和之前一样的内存不足错误,还是不同的错误?我试着运行了2分钟,这里没有错误,内存也保持恒定。

          另外,你用的NodeJS版本是什么?

          1. 是的,和之前一样的错误。内存使用量保持不变一段时间后,在崩溃前突然急剧上升。

            Node.js版本是v10.24.0。(来自Debian 10仓库的默认版本)

            1. 嗯,看来是个旧的内存泄漏问题。在v10.24.0上运行时我也遇到了崩溃。

              经过在几个版本上的快速测试,似乎在v11中至少修复了这个问题(未测试任何次要/补丁版本)。

              顺便说一下,所有版本直到 NodeJS 12(LTS)都已“生命周期结束”,如果下载第三方依赖项,最好不要使用这些版本,因为自那时以来有许多安全修复程序,但并未回溯到旧版本。

      3. > 我认为这是因为 while(1) 运行得太快,导致 V8 没有“空闲”时间来实际运行垃圾回收。V8 是个奇怪的引擎,这只是我的猜测。

        Java 也有过类似的奇怪特性,虽然不会导致崩溃,但根据语言插入安全点的机制(即虚拟机处于可知状态且线程可安全暂停进行 GC 或其他操作的位置),不同构造可能会导致性能下降。

        我不确定这在今天是否仍然成立,但我知道曾经有一段时间,你基本上想避免循环遍历长类型变量,因为它们有不同的语义。细节对我来说现在有点模糊。

    2. 如果你需要快速向管道写入随机字符,GNU coreutils 提供了 yes(1) 工具。在我的系统上,它运行速度约为 6 GiB/s:

        yes | pv > /dev/null
      

      有一篇文章 [1] 提到,考虑到其原始用途,yes(1) 经过了极致优化。如果你好奇的话,yes(1) 原本是用于那些需要反复询问是否继续的命令,这些命令通常会期待 y/n 输入或其他类似输入。这样你就不必反复输入 “y”,只需运行 “yes | the_command” 即可。

      我不确定 yes(1) 与链接文章中提到的技术相比如何。或许还有改进的空间。

      [1] 之前的 HN 讨论:https://news.ycombinator.com/item?id=14542938

      1. 更快的方法是

          pv < /dev/zero > /dev/null
        
        1. 是的,但你无法控制写入的字符(仅限NULL)。

          yes允许你指定要输出的字符。例如'yes n'可输出n。

          1. yes不仅让你选择字符,还让你选择要重复的字符串。例如

                yes 123abc
            

            将打印

                123abc123abc123abc123abc123abc
            

            等等。

            1. 每次以换行符结束,因此:

                123abc
                123abc
                123abc
                ...
              
      2. > 在我的系统上,它大约以 6 GiB/s 的速度运行…

        诚恳地问:这种情况的实际应用场景是什么?

        反复将 ‘y’ 字符输入到 Linux 管道中肯定不常见,尤其是在那个比特率下。而且似乎瓶颈总是会出现在消费程序上…

        1. 历史上,重启后可能会出现不干净的文件系统,导致 ‘fsck’ 提出大量荒谬的问题(“blah blah blah inode 1234567890 修复?(y/n)”)。除非处于非常特定的场景,否则你很可能直接回答 “y”。但它确实可能提出成千上万个问题。因此:“yes | fsck”并不罕见。

          1. > 历史上

            这在安装脚本中可能仍然常见,比如在 Dockerfile 中。apt-get install-y 选项,但对于其他没有该选项的程序来说,这将非常有用。

            1. 仅澄清一点:我使用“历史上”一词指的是“fsck”,而非“yes”的通用用法。我记不清上一次需要使用“yes | fsck”是在何时了

        2. > 诚恳地问:这个功能的实际应用场景是什么?

          它还允许你通过脚本化方式执行原本需要交互式操作的命令行操作,并给出正确答案。如今许多工具都提供了特定选项来覆盖查询。但仍有少数工具可能不支持此功能。

        3. > 在Linux管道中反复输入'y'字符的情况肯定不常见,尤其是在那种传输速率下。

          在那个速率下确实不常见,但我确实偶尔会用到。例如,当我复制大量文件时,系统会反复询问是否覆盖目标文件(即使目标文件已存在)。当然,我可以恢复命令并使用正确的标志来“cp”或其他命令进行覆盖,但通常直接恢复上一行,跳转到开头(C-a),然后输入“yes | ”并完成操作会更快。

          注意,你可以向“yes”传递一个参数,然后它会重复你传递的内容而不是‘y’。

        4. > 尤其是在那个比特率下。似乎瓶颈总是消费程序…

          它不是为了快速而设计的;它只是天生快速,因为它不需要进行其他计算,只需输出字符串。

          1. 它经过了相当认真的优化。我记得曾有过一次与 BSD 版本的比较,后者虽然更慢,但可读性高出数千倍。

            1. 我使用GNU和FreeBSD版本时都达到了约3.10GiB/s。我注意到GNU版本有一些优化,但这些优化在执行yes | pv > /dev/null时效果并不明显。

              不过,我的重点只是它的性能从来都不是主要考虑因素。即使没有优化,它仍然非常快,我认为最初创建它的人并不关心它必须超级快,只要它比管道下游的提示更快就行。

        5. 是的,可以重复任何字符串,而不仅仅是“y”。这对于基本的负载生成很有用。

          1. 我曾用它来测试一些数据库行为,使用命令yes ‘insert ...;’ | mysql ...。这是我能想到的最快的插入操作。

    3. 一个主要影响因素是语言是否默认缓冲输出以及缓冲区大小。我认为 NodeJS 不缓冲,而 Python 会缓冲。以下是与 Go 的比较(Go 默认不缓冲):

      – Node(不缓冲):1.2 MiB/s

      – Go(不缓冲):2.4 MiB/s

      – Python(8 KiB缓冲区):2.7 MiB/s

      – Go(8 KiB缓冲区):218 MiB/s

      Go程序:

          f := bufio.NewWriterSize(os.Stdout, 8192)
          for {
             f.WriteRune(‘1’)
          }
      
      1. 这并非特指你,但看着年轻一代的程序员重新发现这类问题颇具趣味性。这类问题在1990年代曾被视为至关重要,但如今在拥有专用API、共享内存或网络协议的现代工作流程中已不再那么关键,因为真正涉及性能瓶颈的数据通常不再通过管道来回传输。

        不少老旧的备份或传输脚本会在管道中额外添加dd等工具,以创建更大且半异步的缓冲区,或在输出时调整块大小以适应接收端更优的处理方式,这在当年高速磁带驱动器时代曾是重大技术挑战。我推测现代硬件设备已具备足够大的静态内存和高速处理器,使这类优化大多变得无关紧要。

      2. 除了在进程内进行缓冲外,Linux(通常)还会对进程的 stdout 进行约 16KB 的缓冲,但不会对 stderr 进行缓冲。

    4. 我进行了相同的测试,但添加了 Rust 和 Bash 版本。我的测试结果如下:

      Rust:21.9MiB/s

      Bash:282KiB/s

      PHP:2.35MiB/s

      Python:2.30MiB/s

      Node:943KiB/s

      在我的情况下,Node 在大约两分钟后没有崩溃。我发现有趣的是,PHP 和 Python 在我的系统上性能相当,但在你的系统上并非如此,但我相信有许多原因可以解释这一点。我并不惊讶 Rust 速度快得多,而 Bash 慢得多,我只是觉得比较一下很有意思,因为我经常使用这些语言。

      Rust:

        fn main() {
            loop {
                print!(“1”);
            }
        }
      

      Bash(echo 和 printf 之间没有明显区别):

        while :; do printf “1”; done | pv > /dev/null
      
      1. 对于 C、C++ 和 Rust 等语言来说,瓶颈主要在于系统调用。在旧机器上使用大缓冲区时,我用 C++ 得到了大约 1.5 GiB/s 的速度。逐个字符写入时,速度低于 1 MiB/s。

            $ ./a.out 1000000 2000 | cat >/dev/null
            缓冲区大小: 1000000, 系统调用次数: 2000, 性能:1578.779593 MiB/s
            $ ./a.out 1 2000000 | cat >/dev/null
            缓冲区大小: 1, 系统调用次数: 2000000, 性能:0.832587 MiB/s
        

        代码如下:

            #include <cstddef>
            #include <random>
            #include <chrono>
            #include <cassert>
            #include <array>
            #include <cstdio>
            #include <unistd.h>
            #include <cstring>
            #include <cstdlib>
        
            int main(int argc, char **argv) {
        
                int rv;
        
                assert(argc == 3);
                const unsigned int n = std::atoi(argv[1]);
                char *buf = new char[n];
                std::memset(buf, ‘1’, n);
        
                const unsigned int k = std::atoi(argv[2]);
        
                auto start = std::chrono::high_resolution_clock::now();
                for (size_t i = 0; i < k; i++) {
                    rv = write(1, buf, n);
                    assert(rv == int(n));
                }
                auto stop = std::chrono::high_resolution_clock::now();
        
                auto 持续时间 = 停止时间 - 开始时间;
                std::chrono::duration<double> 秒数 = 持续时间;
        
                std::fprintf(stderr, “缓冲区大小: %d, 系统调用次数: %d, 性能:%f MiB/sn”, n, k, (double(n)*k)/(1024*1024)/secs.count());
            }
        

        EDIT: 此外,请注意,向管道写入大量数据(大于 PIPE_BUF)可能需要读取侧进行多次系统调用。

        EDIT 2: 此外,似乎内核足够智能,在明确不需要时不会进行任何复制。当我不通过 cat 时,我得到的速率远高于内存带宽,这表明它实际上没有做任何工作:

            $ ./a.out 1000000 1000 >/dev/null
            缓冲区大小:1000000,系统调用次数:1000,性能:1827368.373827 MiB/s
        
        1. 我怀疑(但不确定)shell可能在流重定向(>)时做了些巧妙的处理,直接将程序的STDOUT文件描述符指向/dev/null。

          不过我可能错了。你可以用lsof或其他工具检查一下。

        2. 无需特殊“无工作”检测。a.out 调用了空设备的 write 函数,该函数仅返回而不执行任何操作。不涉及管道。

        1. 似乎在缓冲输出,Python 也是如此。如果每次写入都刷新,Python 会慢得多(默认 2.6 MiB/s,flush=True 时 600 KiB/s)。

          有趣的是,Go 采用 8 KiB 缓冲区(与 Python 相同)时速度极快,我测得 218 MiB/s。

      2. 对于 Bash 场景,写入两个字符时 fork 的开销远大于任何与 I/O 相关的操作。

          1. > Echo 和 printf 是 bash 中的内置命令。

            啊,是的,说得对,我错了。

      3. 使用 Rust,您还可以避免在 STDOUT 上使用锁,速度会更快!

        1. 测试过,速度大约翻倍(从 22.3 MB/s 到 47.6 MB/s)。

    5. > python3 -c ‘while (1): print (1, end="")’ | pv > /dev/null

      Python 实际上会缓冲其写入操作,仅偶尔将内容 flush 到 STDOUT,你可能想尝试:

          python3 -c ‘while (1): print (1, end="", flush=True)’ | pv > /dev/null
      

      我发现这种方式速度明显更慢(550Kib/s)

    6. Luajit 使用 print 和 io.write

        LuaJIT 2.1.0-beta3
      

      使用 print 大约为 17 MiB/s

        luajit -e “while true do print(‘x’) end” | pv > /dev/null
      

      使用 io.write 约为 111 MiB/s

        luajit -e “while true do io.write(‘x’) end” | pv > /dev/null
      
    7. “JavaScript” 速度最慢,可能是因为 Node.js 将写入操作推送到线程中,而非像 PHP 那样直接从主进程打印输出。

      Python 通过缓冲输出(以 8192 字节为单位缓冲,而非逐字节写入)来“作弊”,但即使这样,速度仍然非常慢。

      C 语言中的 write(1, “1”, 1) 循环在我的电脑上可达 6.38MiB/s。 🙂

      1. 为什么使用缓冲区算作弊?如果你在 C 中使用标准库(putc/fputc)而非系统调用(write),就会得到同样的行为。

        1. 因为这并未回答“不同语言向管道写入单个字符的速度”这个问题,毕竟有些语言确实做不到。

          当然这不是语言“作弊”。只是 OP“测量了错误的指标”。

    8. 添加一些结果:

      使用 OP 的代码进行以下操作

          php 1.8 Mb/秒
          python 3.8 Mb/秒
          node 1.0 Mb/秒
      

      Java 打印 1.3 Mb/秒

          echo ‘class Code {public static void main(String[] args) {while (true){System.out.print(“1”);}}}’ >Code.java; javac Code.java ; java Code | pv>/dev/null
      

      Java 带缓冲 57.4 Mb/秒

          echo ‘import java.io.*;class Code2 {public static void main(String[] args) throws IOException {BufferedWriter log = new BufferedWriter(new OutputStreamWriter(System.out));while(true){log.write(“1”);}}}’ > Code2.java ; javac Code2.java ; java Code2 | pv >/dev/null
      
    9. `process.stdout.write` 与 PHP 的 `echo` 和 Python 的 `print` 不同,它会将写入操作推送到事件队列而不会等待结果,这可能导致事件队列被写入操作填满。相反,你可以考虑使用 `await` 等待 `write` 操作完成后再将另一个 `write` 操作推入事件队列。

          node -e '
              const stdoutWrite = util.promisify(process.stdout.write).bind(process.stdout);
              (async () => {
                  while (true) {
                      await stdoutWrite(“1”);
                  }
              })();
          ' | pv > /dev/null
      
    10. 我使用的是 2015 款 MB Air,同时运行两个浏览器(可能有十几个标签页),iTerm2 中有三个标签页,以及 Outlook、Word 和 Teams 正在运行。

      Perl 5.18.0 的传输速率为每秒 3.5 MiB。Perl 5.28.3、5.30.3 和 5.34.0 的传输速率均为每秒 4 MiB。

          perl5.34.0 -e ‘while (){ print 1 }’ | pv > /dev/null
      

      对于 Python 3.10.4,按照你写的方式,我得到大约 2.8 MiB/s,但使用这个方法时,速度约为 5 MiB/s(3.9 也是如此,但 3.8 只有 4 MiB/s)。我使用 2.7 时也得到 4.8 MiB/s:

          python3 -c ‘while (1): print (1)’ | pv > /dev/null
      

      如果让 Perl 像 yes 命令一样打印一个字符和换行符,它会有自己的速度提升。以下代码给我 37.3 MiB 每秒:

          perl5.34.0 -e ‘while (){ print1n” }’ | pv > /dev/null
      

      有趣的是,使用 Perl 的 say 函数(类似于 Println)会显著降低速度。此版本仅为 7.3 MiB/s。

          perl5.34.0 -E ‘while (1) {say 1}’ | pv > /dev/null
      

      Go 1.18 使用 fmt.Print 时为 940 KiB/s,使用 fmt.Println 时为 1.5 MiB/s,供参考。

          package main
      
          import “fmt”
      
          func main() {
                  for ;; {
                          fmt.Println(“1”)
                  }
          }
      

      这些都是 macports 构建的版本。

    11. 对我来说:

      Python3:3 MiB/s

      Node:350 KiB/s

      Lua:12 MiB/s

        lua -e ‘while true do io.write(“1”) end’ | pv > /dev/null
      

      Haskell:5 MiB/s

        loop = do
          putStr “1loop
      
        main = loop
      

      Awk:4.2 MiB/s

        yes | awk ‘{printf(“1”)}’ | pv > /dev/null
      
      1. Lua 是一个有趣的案例。

            while true do
              io.write1end
        

        PUC-Rio 5.1: 25 MiB/s

        PUC-Rio 5.4: 25 MiB/s

        LuaJIT 2.1.0-beta3: 550 MiB/s <— 哇

        如果将对 io.write 的引用本地化,它们都会稍微快一点

            local write = io.write
            while true do
              write1end
        
        1. > 如果你将对 io.write 的引用本地化,它们都会稍微快一点

          对于 LuaJIT 来说没有明显差异,这有道理,因为 JIT 应该能自行处理。

          1. 这就是为什么我们需要不可变模块。如果在运行时之前就知道某个值是什么,查找速度会快得多。

          2. 啊,你说得对。基本上 LuaJIT 没有区别。

            5.1 和 5.4 显示约 8% 的改进。

      2. Haskell 可以更简单:

            main = putStr (repeat1’)
        

        [编辑:如下面指出的,情况已不再如此!]

        在 Haskell 中,字符串会逐个字符打印。这种选择是由于懒惰评估与缓冲之间的相互作用不可预测;我不确定这是正确的选择,但正确的应对方式是在性能相关的情况下使用 Text。

        1. 哇,这达到了 160 MiB/s。这是巨大的提升!strace 的输出完全不同:

            poll([{fd=1, events=POLLOUT}], 1, 0)    = 1 ([{fd=1, revents=POLLOUT}])
            write(1, “11111111111111111111111111111111”..., 8192) = 8192
            poll([{fd=1, events=POLLOUT}], 1, 0)    = 1 ([{fd=1, revents=POLLOUT}])
            write(1, “11111111111111111111111111111111”..., 8192) = 8192
          

          使用递归代码时,它以相同的方式缓冲输出,但在两次写入之间对内核造成了更多干扰。不太确定具体发生了什么:

            poll([{fd=1, events=POLLOUT}], 1, 0)    = 1 ([{fd=1, revents=POLLOUT}])
            write(1, “11111111111111111111111111111111”..., 8192) = 8192
            rt_sigprocmask(SIG_BLOCK, [INT], [], 8) = 0
            clock_gettime(CLOCK_PROCESS_CPUTIME_ID, {tv_sec=0, tv_nsec=920390843}) = 0
            rt_sigprocmask(SIG_SETMASK, [], NULL, 8) = 0
            rt_sigprocmask(SIG_BLOCK, [INT], [], 8) = 0
            clock_gettime(CLOCK_PROCESS_CPUTIME_ID, {tv_sec=0, tv_nsec=920666397}) = 0
            ...
            rt_sigprocmask(SIG_SETMASK, [], NULL, 8) = 0
            poll([{fd=1, events=POLLOUT}], 1, 0)    = 1 ([{fd=1, revents=POLLOUT}])
            write(1, “11111111111111111111111111111111”..., 8192) = 8192
          
          1. 我真的没想到这两种情况都会被缓冲!这一定是自从我不再那么关注GHC后发生的变化。

            我也不确定第二种情况到底发生了什么。据我所知,历史上某个时候,一个足够紧凑的循环可能会导致处理 SIGINT 时出现问题,所以这可能与某种过于激进的解决方法有关?

    12. 在我那台非常老旧的台式电脑(Phenom II 550)上运行一个过时的操作系统(Slackware 14.2):

      Bash:

          while :; do printf “1”; done  | ./pv > /dev/null
          [ 156KiB/s]
      

      Python3 3.7.2:

          python3 -c ‘while (1): print (1, end="")’ | ./pv > /dev/null
          [1,02MiB/s]
      

      Perl 5.22.2:

          perl -ewhile (true) {print 1}’  | ./pv > /dev/null
          [3,03MiB/s]
      

      Node.js v12.22.1:

          node -e ‘while (1) process.stdout.write(“1”);’ | ./pv > /dev/null
          [ 482KiB/s]
      
    13. 除了潜在的缓冲问题外,正如其他人指出的,Node.js 的示例正在执行异步写入,而其他语言的示例则不是(据我所知)。

      要进行正确的同步写入,你可以这样做:

        node -e ‘const { writeSync } = require(“fs”); while (1) writeSync(1, “1”);’ | pv > /dev/null
      

      这让我在 node v18.1.0 和内核 5.4.0 上得到了 ~1.1MB/s 的速度。

    14. 你在每种语言中测试一个非常具体的操作(循环),以确定其速度,我不确定是否能将此结果推广。我好奇如果用数千个字符长的静态打印语句(带换行符)替换循环,会是什么效果,这种情况类似于编译器优化所做的事情。

    15. 我发现,即使没有内存泄漏,NodeJS 在长时间进行大量数据处理且几乎没有中断的应用程序中,最终会耗尽内存并崩溃。

      编辑:我发现,在多年和多家公司中,构建多个数据处理应用程序时,这种情况一直存在。

    16. 我来告诉你有趣的地方。我用Python能达到5MB/秒,用Node能达到1.3MB/秒,而用Ruby能达到12.6MB/秒! :-)(补充:如果我使用$stdout.sync = true,速度与Node相同。)

    17. 在我的M1 Pro上,Python可以达到15MiB/秒,如果你直接使用sys的话。

         python3 -c 'import sys
         while (1): sys.stdout.write(“1”)'| pv>/dev/null
      
      1. 不过这会缓存。你可以通过 strace 看到。

        1.     python3 -u -c 'import sys
                while (1): sys.stdout.write(“1”)'| pv>/dev/null
          

          427KiB/s

              python3 -c 'import sys
                while (1): sys.stdout.write(“1”)'| pv>/dev/null
          

          6.08MiB/s

          使用 python 3.9.7 在 macOS Monterey 上。

        2. 说得对,但标准的打印调用也是如此。在每次写入后调用 flush() 确实将性能提升至 1.5MiB

    18. 我得到的结果因运行时间不同而有所差异。花了一会儿才意识到这是由于处理器频率调节造成的。

    19. 你使用的是哪个版本的 Node?在随 Ubuntu 20.04 附带的 14.19.3 版本上,它似乎可以无限运行。

    20. 使用 `sys.stdout.write()` 代替 `print()` 在我的机器上可以达到 ~8MiB/s。

  26. 这个网站看起来很赏心悦目。

  27. 喜欢第一个图表上微妙的“stonks”叠加效果

  28. 这就是我来HN的目的。绝对引人入胜的阅读内容。

  29. Android的Linux版本使用“binder”而非管道,这是由于其安全模型。依我之见,基于文件系统的IPC机制(尤其是管道)无法使用,因为缺乏可供所有人写入的目录——我可能在这里错了。

    Binder实际上源自Palm(OpenBinder)

    1. 管道并不一定意味着必须使用文件系统权限。例如,服务器可以通过 Unix 域套接字的文件描述符传递,向授权客户端分配匿名管道。服务器可以在执行此操作之前实现任意权限检查。

    2. “缺乏可供所有人写入的目录”

      那是什么?

      许多程序将套接字存储在/run目录中,该目录通常由`tmpfs`实现。

    3. 绑定器的历史更为复杂,其起源可追溯至BeOS(据我所知)。

  30. 我通常使用cat /dev/urandom > /dev/null来生成负载。不确定这与他们的代码相比如何。

    编辑:实际上我之前使用的是“yes”来生成负载。我记得曾在某处读到“yes”与原始Unix命令的优化方式不同,这是作为Unix认证诉讼的一部分。

    漫长的一夜。

    1. 在 5.10.0-14-amd64 系统上,“pv < /dev/urandom >/dev/null” 报告 72.2MiB/s。而 “pv < /dev/zero >/dev/null” 报告 16.5GiB/s。AMD Ryzen 7 2700X 搭配 16GB DDR4 3000MHz 内存。

      “tr ‘’ 1 /dev/null” 报告 1.38GiB/s。

      “yes | pv >/dev/null” 报告 7.26GiB/s。

      因此,在测试性能时,“/dev/urandom” 可能不是最佳数据源。

      1. 你说得对,我打错了,我通常使用的是 yes。我认为它优化了吞吐量。

      2. 他们是在生成负载吗?通过urandom设备传输数据不算太差,因为它需要做一些工作来获取随机数。不过,如果只是为了吞吐量,那么使用零可能更好。

        1. 我不明白。如果你在测试管道的速度,那么我预计你会测量吞吐量或延迟。为什么你要测量与管道无关的东西的速度?如果你想测量这个其他东西,为什么还要使用管道,因为管道会给测量带来噪音?

          更新:如果你指的是想测试在系统中有其他负载时管道的速度,那么我建议你只是在后台运行很多东西。但我不建议将专门用于执行其他任务的进程放入你正在测量的管道中。事实上,我给出的数据是在后台运行大量高负载进程的情况下测得的,例如Firefox、Thunderbird、运行另一个Firefox实例的虚拟机、OpenVPN等。:)

          1. 因为他们提到的是生成负载,而不是测试管道性能。

            1. 哦,等等。你的意思是这个“cat /dev/null”应该在后台运行,而不是作为被测试的管道?好吧,是我没明白你的意思。

        2. “生成负载”用于测量管道性能意味着生成字节。任何字节。urandom对此非常糟糕。

  31. 我很高兴巨大页面能带来显著差异,因为我刚花了几个小时设置它们。大家都说要禁用 transparent_hugepage,所以我将其设置为 `madvise`,但我怀疑除了数据库外,其他程序是否真的会使用它们。

    1. JVM 可以。我已将 JetBrains 配置为使用它们。

  32. 没错,你想提升性能?那就不要使用互斥锁后再 yield,改用自旋并检查 CPU 散热器。

    🙂

  33. 可能是个愚蠢的问题,但为什么管道不能简单地实现为共享内存段中的连续缓冲区加上一个 futex?

  34. 可能有点相关。

    我刚安装了25Gb/s的互联网(https://www.init7.net/en/internet/fiber7/),在这种速度下,Chrome和Firefox(基于Chrome)在使用speedtest.net时,大约在10-12Gbps时就会崩溃。

    具体表现为整个标签页冻结,显示的网速从10-12Gbps骤降至<1Gbps,且页面更新频率降至每秒一次左右。

    据我所知,基于Chrome的浏览器会通过某种形式的进程间通信(IPC)与独立的网络进程进行交互,该进程实际负责网络处理。我猜测这可能与Linux系统中套接字对/管道的本地速度限制被触发有关,因此出现上述现象。

    1. > 在这些速度下,Chrome 和 Firefox(基于 Chrome)

      据我所知,Firefox 并非基于 Chrome。

      在 iOS 上,它使用 iOS 提供的 WebView 功能——Chrome 在 iOS 上也是如此。

      Firefox 和 Safari 是目前唯一支持的主流浏览器,它们拥有自己的渲染引擎。Firefox 是唯一拥有自己的渲染引擎且跨平台的浏览器。它也是开源的。

      1. > Firefox 是唯一拥有自己的渲染引擎且跨平台的浏览器。

        有趣的是,Safaris渲染引擎是开源且跨平台的,但浏览器本身并非如此。许多专注于Linux的浏览器(如Konqueror、GNOME Web、Surf)以及大多数嵌入式浏览器(如任天堂DS和Switch、PlayStation)都使用WebKit。此外,一些用户界面(如WebOS,该系统运行于LG的所有电视和智能冰箱)也采用WebKit作为其渲染引擎。

        1. 顺便说一下,WebKit本身是Konqueror原始KHTML引擎的分支。

      2. iOS 使用 WebKit,这也是 Chrome 的基础。

        1. Chrome 使用 Blink,该引擎于 2013 年从 WebKit 的 WebCore 分支出来。他们用 V8 替换了 JavaScriptCore。

      3. > 据我所知,Firefox 任何地方都不是基于 Chrome 的。

        严格来说不是“基于 Chrome”,但 Firefox 使用 Chrome 的 Skia 图形引擎来渲染图形。

        Firefox并非完全独立于Chrome。

        1. Skia于2004年独立于谷歌开发,后被谷歌收购。称其为“Chrome的Skia图形引擎”会让人误以为它是为Chrome专门开发的。

        2. 我认为逐一统计每个库是没有意义的。

          无论如何,我以为 Chrome 使用的是 Mozilla 的 libnss 库,所以也可以反过来讲。

    2. Chrome 会启动多个进程,并在它们之间建立基于 IPC 的通信网络以实现隔离。这在某种程度上是在滥用操作系统来实现隔离等需求。

      (这与K8S滥用ip-tables并使其无法用于其他目的类似,迫使你在入口路径前安装专用防火墙,但我们不深入讨论这一点。)

      另一方面,Firefox既不是基于Chromium,也不是其衍生版本。它是一个完全不同的代码库,源自Netscape时代并发展至今。

      作为另一个测试点,Firefox在面对对称千兆连接全速运行时毫无压力(我的网络受网卡限制,实际带宽要大得多)。

      1. 它使用的是最初创建的操作系统进程。

        不幸的是,安全行业已经证明,当安全是首要关注点时,线程对应用程序来说是个糟糕的主意。

        同样适用于动态加载的代码插件,主应用程序需为其引入的不稳定性和漏洞承担责任。

        1. 是的,Firefox 也在做同样的事情,但由于 Firefox 进程的特性,当我因研究需要打开 50 多个标签页时,操作系统不会失去太多响应性或感到卡顿。

          如果你需要安全性,你就需要隔离。如果你想要硬件级别的隔离,你就需要进程。这是正常的。

          我对谷歌应用程序的异议在于,它们的行为似乎认为自己是系统上唯一运行的进程。我非常清楚,一些性能最佳或最安全的实现方式在纸面上可能并不美观。

          1. 以前有一个设置可以调整Chrome的进程行为。

            我认为默认行为是“如果标签页来自同一信任域,则将它们合并到同一个内容进程中”。

            然后你可以让它更激进,比如“永远不要合并标签页”,或者更保守,比如“只使用一个内容进程”。我认为是这样。

            我不确定Firefox在何时决定创建新进程。我知道他们有一个 GPU 进程,然后有多个不受信任的“内容进程”,这些进程可以访问不受信任的数据,但不能访问 GPU。

            我不在乎。这是安全性和开销之间的权衡。进程间通信(IPC)相当高效,而且在 Windows 和 Linux 中,页面缓存 _应该_ 意味着所有代码页面都在所有内容进程之间共享。

            静态页面对我来说感觉很轻量。我认为糟糕的网页应用让网络变慢,而不是浏览器安全。

            (提前说明,我是在回复某个在 Firefox IPC 团队工作的人,哈哈)

            1. > 提前说明,我是在回复某个在 Firefox IPC 团队工作的人,哈哈

              在 HN 上评论的危险与乐趣!

              1. 我无害,别担心。:) 此外,你可以在我的个人资料中找到更多关于我的信息。

                即使我正在参与 Firefox/Chrome/或其他项目的开发,我也不会对那些不太了解某件事的人感到生气。为什么我要生气呢?我们只是在这里交流。

                此外,我在这儿也犯过很多错误,这大大提升了我的交流/讨论能力。

                所以,别担心,尽情评论吧。

      2. > 作为另一个测试点,Firefox在对称千兆连接全速运行时甚至不会有任何反应(我的网络受网卡限制,带宽其实更宽)。

        供参考,Linux 下的 Firefox(Firefox 浏览器 100.0.2(64 位))与 Chrome 的行为几乎相同。速度会迅速提升至 5-8Gb/s,随后界面开始卡顿,显示的速度降至 500Mb/s。这可能是操作系统本身存在调度限制或其他瓶颈,假设这些是不同的代码库(它们是吗?)。

        1. 我非常想测试并调试导致问题的位置,但我们所有安装了 Firefox 的系统都没有那么宽的管道(再次受网卡限制)。

          不过,你可以通过安装Speedtest的命令行版本并连接附近的服务器来测试Linux的性能极限。

          瓶颈可能出在浏览器本身,也可能出在你的图形栈中。

          Linux在网络方面能做到相当惊人的事情,否则Linux服务器上就不可能有100Gbps的InfiniBand卡,而我们系统上确实有。

          是的,Chrome和Firefox是截然不同的浏览器。我可以肯定地说,因为我从Netscape 6.0(以及Knoppix中的Mozilla)时代就开始使用Firefox。

          1. 根据我多年前的经验,Linux下的高性能网络传统上都是用户空间和预分配池(如netmap、DPDK、pf-ring等)。我不清楚io_uring在网络栈使用方面追赶了多少… 也许其他人知道?

            1. 虽然我对具体细节不太了解,但现在Linux中有许多网络路径。传统的基于内核的路径仍然存在,还有一些基于内核旁路的[0]路径,用于非常高性能的网卡。

              此外,InfiniBand 可以直接与 MPI 进程进行 RDMA 通信,实现“远程内存本地化”,从而在 HPC 环境中实现极低延迟和高性能。

              我也很喜欢 Cloudflare 的这篇帖子 [1]。我已经完整阅读过,但由于我并不直接参与我们系统中的网络部分,所以具体细节对我来说有些模糊。

              [0]: https://medium.com/@penberg/on-kernel-bypass-networking-and-

              [1]: https://blog.cloudflare.com/how-to-receive-a-million-packets

            2. 我有一个服务,其性能优于 epoll,使用 io_uring(它从一个套接字读取 GRE 数据包,对内部数据包进行一些查找/处理,然后重新封装到不同的机制中,并写回另一个套接字)。io_uring 与 epoll 的通用使用场景大致相当,据我所知。不过,如果流(如 TCP)通过 io_uring 和缓冲区注册实现更快,我也不会感到意外。

              完全无关的补充——io_uring 似乎正在超越单纯的 I/O,演变为一种替代的系统调用接口,这在我看来相当不错。

          2. > 我可以肯定地说,因为我从 Netscape 6.0(以及 Knoppix 中的 Mozilla)时代就开始使用 Firefox。

            Mozilla 套件/Seamonkey 通常不被视为与 Firefox 相同,尽管显然相关。

            1. 我指的不是演变为 Seamonkey 的版本。我指的是 Mozilla/Firefox 0.8,它在右上角使用 Mozilla 标志作为“旋转图标”,而非 Netscape 标志。

                1. > Netscape 6 并非基于 Firefox。

                  我知道。当 Netscape 6 发布时,Firefox 甚至还未被提出。然而,情况恰恰相反。Firefox 实际上是基于 Netscape 的。它只是从 SeaMonkey 分支出来的。据说它最初是 SeaMonkey 的精简版。

                  我记得在 Knoppix 3.x 时代,SeaMonkey/Mozilla 套件中的“Mozilla Navigator”比 Firefox 更早出现,且在三年后停产。我刚用 CD 启动它查看了一下。

                  归根结底,Firefox 就是进化的 Netscape Navigator。

    3. Firefox 并非基于 Chromium 代码库,它更早。

      1. 如果我们谈论祖先,这在技术上是正确的,但差距不大——Firefox 源自 Netscape,而 Chrome/Safari/… 源自 KHTML。

        1. > … 1999 年 8 月 16 日,[Lars Knoll] 提交了对 KHTML 库的完整重写——将 KHTML 改为使用 W3C DOM 标准作为其内部文档表示。https://en.wikipedia.org/wiki/KHTML#Re-write_and_improvement

          > 1998年3月,网景公司将其广受欢迎的网景通讯套件(Netscape Communicator)的大部分源代码以开源许可证的形式发布。从该代码库开发而来的应用程序名称为Mozilla,由新成立的Mozilla组织https://en.wikipedia.org/wiki/Mozilla_Application_Suite#Hist…负责协调。

          网景通讯器(或网景4)于1997年发布,因此若追溯血统,我认为火狐有两年的先发优势。

        2. 据我所知,KHTML与Netscape/Gecko没有任何关联。

    4. 无关问题,你使用什么硬件来搭建25Gb/s的网络?我一直在研究init7,但尝试寻找合适的硬件后放弃了,最终还是选择了Salt。

      1. 网卡:Intel E810-XXVDA2

        光模块:连接 ISP:Flexoptics (https://www.flexoptix.net/de/p-b1625g-10-ad.html?co10426=972…), 路由器-PC: https://mikrotik.com/product/S-3553LC20D

        路由器:Mikrotik CCR-2004 – https://mikrotik.com/product/ccr2004_1g_12s_2xs – 警告: 它可以支持单向约20Gb/s的速率。它可以处理约25Gb/s的下行速率,但上行速率仅约18Gb/s,而使用IPv6时,任何方向的最大速率似乎约为10Gb/s。

        如果你对使用Mikrotik设备感到熟悉,也可以考虑https://mikrotik.com/product/ccr2216_1g_12xs_2xq – 它更昂贵(约2500欧元),但应能轻松处理25Gb/s。

        1. 据我所知,大多数Mikrotik产品缺乏硬件IPv6卸载功能,这可能是你看到速度较低的原因。

          1. 在这种情况下,10Gb/s的速度实际上相当不错,如果没有硬件卸载的话。

    5. Speedtest也有命令行界面(CLI),或许可以对比一下。

      1. 需注意:GitHub上的开源版本(可通过Homebrew和原生包管理器安装)与Ookla官网分发的版本不同,且完全不准确。

    6. 这让我好奇……有没有人提供基于 iperf 的网速测试服务?

      1. 哈哈,我的ISP确实提供 🙂 当我直接连接(绕过路由器,因为它几乎无法处理25Gb/s)时,我可以达到25Gb/s。

        通过路由器时,我只能获得约15-20Gb/s

          $ iperf3 -l 1M --window 64M -P10 -c speedtest.init7.net
          ..
          [SUM]   0.00-1.00   sec  1.87 GBytes  16.0 Gbits/sec  181406
        
          $ iperf3 -R -l 1M --window 64M -P10 -c speedtest.init7.net
          ..
          [SUM]   0.00-1.00   sec  2.29 GBytes  19.6 Gbits/sec
        
    7. 这只是影响浏览器还是整个系统?可能是 CPU 正在处理以太网控制器发出的中断,尽管通常这些控制器应使用 DMA 且不应频繁发送中断。

    8. 我在数据中心的VDI环境中遇到了这个问题。我们最初为虚拟机提供了10Gb以太网,因为为什么不呢。

      结果发现Windows 7或网卡需要大量调优才能正常工作。出现了大量卡顿和其他故障。

      1. 它基于 Safari,而 Safari 基于 WebKit。Chrome 在 iOS 上也基于 Safari,因为所有浏览器都必须如此。iOS 上实际上没有 Chrome(指 Blink 浏览器引擎),至少在 Play 商店中没有。

        1. > 它基于 Safari,而 Safari 基于 WebKit。

          Firefox 在 iOS 系统上仅使用 WebKit,这是由于苹果公司的要求。在其他平台上,它使用 Gecko 引擎。而且我认为它从未在任何地方基于 Safari。

          1. 回复

            > Firefox 在 iOS 系统上仅基于 Chrome。

            所以我只在谈论iOS。当我说它是基于Safari时,我的意思是基于WebKit,但我以为Firefox/Chrome实际上在iOS上调用了Safari的部分组件。快速查证显示这是错误的,它们只是使用了WebKit。我不是iOS开发者,所以有人可以指出更准确的术语来源。

    9. 你指的是Gbit/s吗?25Gb/s相当于200Gbit/s……

      1. 小写“b”通常用于表示比特,大写“B”用于表示字节。因此,25 Gb/s等于25 Gbit/s,而25 GB/s等于200 Gbit/s。

      2. Gb ≠ GB。根据维基百科的解释,这与我的理解一致,

        “千兆位(gigabit)的单位符号为 Gbit 或 Gb。”

        25GB/s 相当于 200Gbit/s,同时也相当于 200Gb/s。

  35. Windows 是否存在类似 vmsplice 的 API?

  36. 喜欢第一张图片中微妙的股票背景。

  37. pv 是用 Perl 编写的,因此速度不算最快,我惊讶于它能获得如此高的评分。我好奇如果它只是写入 /dev/null,初始速度会是多少。

    1. 它不是用 Perl 编写的,而是用 C 编写的,并且使用了 splice()(帖子中讨论的系统调用之一)。

      1. 我完全错了。感谢你指出事实。

  38. Linux 管道?

    哦,是的,Linux 管道是由道格拉斯·麦克伊洛伊在贝尔实验室工作期间为研究版 UNIX 开发的,首次在 1974 年 2 月的 Version 3 Unix 手册页中描述,距离林纳斯·托瓦兹的第四个生日仅过去几个月。

    何时何地何种情况下,对Linux的不公正且明显的剽窃行为才会停止?该软件由BSD开源,因此请随意使用它,将其整合到GNU/Linux中,尽情使用,但请停止错误地将这些东西描述为Linux的东西。因为我确信唯一真正属于Linux的软件是systemd。所以让我们开始称其为“Linux systemd”,并停止将其他任何东西称为Linux。

    1. > 在这篇文章中,我们将探讨Unix管道在Linux中的实现方式

      在我看来,这篇文章显然是专门讨论Linux上的Unix管道(即“Linux管道”),而非泛指Unix管道。

      文章还提到了“Linux分页”,显然指的是Linux中虚拟内存的实现和使用,而非……无论哪种古老架构首先发明了页表。

      1. 这在理论上成立,但仅限于Linux中管道的实现与其他管道不同,无论内存分页的差异如何,而文章尽管详尽深入,却从未明确说明这一点。

        因此,我不确定这是否类似于“今天,我们将讨论Linux电力,具体是Linux中电力的利用方式”。

    2. 当有人说的话有多种解读方式时,通常最好假设对方是出于善意。在这种情况下,Linux管道指的是Linux中的管道,而非暗示所有权或来源。这可以避免许多不必要的争论

      1. 那么Linux中的管道与其他管道有显著差异吗?当Linux从Minix发展而来时,是否真的重新发明了轮子,以至于放弃了Minix的管道实现?真的吗?!我表示怀疑。

        1. 这就是语言的运作方式。你可以说“沙漠的太阳让我口渴”,即使事实上是同一颗太阳。它甚至不在沙漠中,也没有做任何特别的事情。然而,人们直觉上知道你的意思。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

你也许感兴趣的: