通过x86-64汇编程序进行管道传输时的竞争条件

我编写了以下简化的cat汇编实现。它使用 linux 系统调用,因为我正在运行 linux。这是代码:

.section .data
.set MAX_READ_BYTES, 0xffff

.section .text
.globl _start

_start:
    movq (%rsp), %r10 # save the value of argc somewhere else
    movq 16(%rsp), %r9 # save the value of argv[1] somewhere else

    movl $12, %eax # syscall 12 is brk. see brk(2)
    xorq %rdi, %rdi # call with 0 as first arg to get current end of memory
    syscall
    movq %rax, %r8 # this is the address of the current end of memory

    leaq MAX_READ_BYTES(%rax), %rdi # let this be the new end of memory
    movl $12, %eax # syscall 12, brk
    syscall
    cmp %r8, %rax # compare the two; if the allocation failed, these will be equal
    je exit

    leaq -MAX_READ_BYTES(%rax), %r13 # store the start of the free area in %r13

    movq %r10, %rdi # retrieve the value of argc
    cmpq $0x01, %rdi # if there are no cli args, process stdin instead
    je stdin

    # open the file
    movl $0x02, %eax # syscall #2 = open.
    movq %r9, %rdi
    movl $0, %esi # second argument: flags. 0 means read-only.
    xorq %rdx, %rdx # this argument isn't used here, but zero it out for peace of mind.
    syscall # returns the file descriptor number in %rax
    movl %eax, %edi
    movl %edi, %r12d # first argument: file descriptor.
    call read_and_write
    jmp cleanup

stdin:
    movl $0x0000, %edi # first argument: file descriptor.
    movl %edi, %r12d # first argument: file descriptor.
    call read_and_write
    jmp cleanup

read_and_write:
    # read the file.
    movl $0, %eax # syscall #0 = read.
    movl %r12d, %edi
    movq %r13 /* pointer to allocated memory */, %rsi # second argument: address of a writeable buffer.
    movl $MAX_READ_BYTES, %edx # third argument: number of bytes to write.
    syscall # num bytes read in %rax
    movl %eax, %r15d

    # print the file
    movl $1, %eax # syscall #1 = write.
    movl $1, %edi # first argument: file descriptor. 1 is stdout.
    movq %r13, %rsi # second argument: address of data to write.
    movl %r15d, %edx # third argument: number of bytes to write.
    syscall # result ignored.
    cmpq $MAX_READ_BYTES, %r15
    je read_and_write
    ret

cleanup:
    # close the file
    movl $0x03, %eax # syscall #3 = close.
    movl %r14d, %edi # first arg: file descriptor number.
    syscall # result ignored.

exit:
    # set the exit code
    movl $60, %eax # syscall #60 = exit.
    movq $0, %rdi # exit 0 = success.
    syscall

我已将其组装成一个名为asmcat. 为了测试这个程序,我有这个文件/tmp/random

$ wc -c /tmp/random
94870 /tmp/random

当我运行以下命令时,结果是一致的:

$ ./asmcat /tmp/random | wc -c
94870

以下是同一命令的两次单独运行:

$ cat /tmp/random | ./asmcat | wc -c
65536

$ cat /tmp/random | ./asmcat | wc -c
94870

将输出重定向到文件一致地生成相同大小的文件:

for i in {0..25}; do
    cat /tmp/random | ./asmcat > /tmp/asmcat-output-$i
done
for i in {0..25}; do
    wc -c /tmp/asmcat-output-$i
done

所有生成的文件都具有相同的大小,94870. 这让我相信管道wc是导致不一致行为的原因。我的程序应该做的就是读取标准输入,一次 65535 个字节,然后写入标准输出。程序中可能存在错误,但是,为什么它会始终重定向到大小一致的文件?所以我强烈的感觉是关于管道的某些事情导致了我的汇编程序输出大小的不一致测量。

欢迎任何反馈,包括汇编程序中采用的方法(我只是为了好玩/练习而写的)。

回答

TL:DR:如果您的程序在cat重新填充管道缓冲区之前执行两次读取,
则第二次读取仅获得 1 个字节。这使您的程序决定过早退出。

这才是真正的错误。使这成为可能的其他设计选择是性能问题,而不是正确性。


您的程序在任何短读(返回值小于请求的大小)后停止,而不是等待 EOF(read() == 0)。这是一种简化,有时对常规文件是安全的,但对其他任何文件都不安全,尤其是不是 TTY(终端输入),但也不适用于管道或套接字。例如尝试运行./asmcat;它在您在一行上按回车键后退出,而不是等待 control-D EOF。

Linux 管道缓冲区默认只有 64kiBpipe(7)手册页),比您使用的奇怪大小的缓冲区大 1 个字节。在cat's write 填满管道缓冲区后,您的 65535 字节读取留下 1 字节剩余。如果您的程序readcat可以再次写入之前赢得了管道竞争,则它仅读取 1 个字节。

不幸的是,在 under 下运行strace ./asmcat会使读取速度减慢太多以观察短读取,除非您也减慢速度cat或任何其他程序来限制输入管道的写入端的速率。

用于测试的限速输入:

pv(1)管道查看器对此很方便,具有速率限制-L选项和缓冲区大小限制,因此您可以确保其写入小于 64k。(不经常进行较大的 64k 写入可能并不总是导致短读取。)但是如果我们只想始终进行短读取,那么从终端运行交互式读取就更容易了。 strace ./asmcat

$ pv -L8K -B16K /tmp/random | strace ./orig_asmcat | wc -c
execve("./orig_asmcat", ["./orig_asmcat"], 0x7ffcd441f750 /* 55 vars */) = 0
brk(NULL)                               = 0x61c000
brk(0x62bfff)                           = 0x62bfff
read(0, "=head1 NAMEnn=for comment  Gener"..., 65535) = 819
write(1, "=head1 NAMEnn=for comment  Gener"..., 819) = 819
close(0)                                = 0
exit(0)                                 = ?
+++ exited with 0 +++   # end of strace output
819                     # wc output
 819 B 0:00:00 [4.43KiB/s] [>              ]  0%        # pv's progress bar

与修复了错误的 相比asmcat,我们得到了预期的短读和同等大小的写序列。(我的版本见下文)

execve("./asmcat", ["./asmcat"], 0x7ffd8c58f600 /* 55 vars */) = 0
read(0, "=head1 NAMEnn=for comment  Gener"..., 65536) = 819
write(1, "=head1 NAMEnn=for comment  Gener"..., 819) = 819
read(0, "check if annamed variable exists"..., 65536) = 819
write(1, "check if annamed variable exists"..., 819) = 819

代码审查

有多个浪费的指令,例如mov写入一个永远不会再读取的寄存器,例如在调用之前设置 EDI,但随后函数调用将 R12D 作为 arg,而不是标准调用约定。

尽早读取 argc, argv 而不是将它们留在堆栈中直到需要它们时同样是多余的。

.data毫无意义:.set是一个汇编时间常数。定义当前部分时,它是什么并不重要。您也可以将其编写为MAX_READ_BYTES = 0xffff更自然的汇编时常量语法。

可以在堆栈上而不是使用 brk 分配缓冲区(它只有 64K - 1,并且 x86-64 Linux 默认允许 8MiB 堆栈),在这种情况下提前加载可能是有意义的。或者只使用 BSS,例如lcomm buf, 1<<16

为了提高效率,最好将缓冲区设为2 的幂,或者至少是页面大小 (4k) 的倍数。如果你用它来拷贝文件,第一次之后的每一次读取都会在接近一页的末尾开始,而不是拷贝整个 4k 页,所以内核的copy_to_user(读)和copy_from_user(写)将接触内核的 17 页每次读/写的内存而不是 16。文件数据的页面缓存可能不在连续的内核地址中,因此每个单独的 4k 页面都需要一些开销来查找,并为(rep movsb在具有 ERMSB 功能的现代 CPU 上)启动单独的 memcpy 。同样对于磁盘 I/O,内核必须将您的写入缓冲回对齐的块,这些块是硬件扇区大小和/或文件系统块大小的倍数。

从管道读取时,64KiB 显然是一个不错的选择,出于同样的原因,这场比赛是可能的。留下 1 个字节显然是低效的。此外,64k 小于 L2 缓存大小,因此当您再次写入时,到/从用户空间(系统调用的内核内部)的复制可以从 L2 缓存中重新读取。但是较小的大小意味着更多的系统调用,并且每个系统调用都有显着的开销(尤其是现代内核中的 Meltdown 和 Spectre 缓解。)

考虑到典型的 256KiB L2 缓存,64KiB 到 128KiB 大约是缓冲区大小的最佳点。(相关:代码高尔夫:yes西方最快的调整一个只进行write系统调用的程序,使用 x86-64 Linux,在我的 Skylake 桌面上使用分析/基准测试结果。)

机器代码中的任何内容都不会像 0xFFFF 那样从 uint16_t 中的大小拟合中受益;int8_t 或 int32_t 与 64 位代码中的立即数操作数大小相关。(或者 uint32_t 如果你零扩展喜欢mov $imm32, %edx零扩展到 RDX。)

不要关闭stdin;你close无条件地跑。关闭标准输入不会影响父进程的标准输入,所以它在这个程序中不应该是一个问题,但重点close似乎是让它更像一个你可以在大型程序中使用的函数。因此,您应该将复制fd到标准输出与文件处理分开。

使用#include <asm/unistd.h>得到呼叫号码,而不是硬编码他们。它们保证稳定,但仅使用命名常量更易于人类阅读/自我记录,并避免任何复制错误的风险。( Build with gcc -nostdlib -static asmcat.S -o asmcat ; GCC.S在汇编之前通过 C 预处理器运行文件,不像.s)

样式:我喜欢将操作数缩进到一致的列,这样它们就不会拥挤助记符。同样,注释应该舒适地位于操作数的右侧,这样您就可以向下浏览该列以查找访问任何给定寄存器的指令,而不会被较短指令的注释分心。

注释内容:指令本身已经说明了它的作用,注释应该描述语义。(我不需要注释来提醒我调用约定,比如系统调用在 RAX 中留下结果,但即使你这样做,用它的 C 版本总结系统调用也可以很好地提醒我哪个 arg 是哪个。喜欢open(argv[1], O_RDONLY)。)

我也喜欢删除多余的操作数大小后缀;寄存器大小意味着操作数大小(就像英特尔语法一样)。请注意,将 64 位寄存器清零只需要 xorl;写入 32 位寄存器隐式零扩展到 64 位。您的代码有时会与应该是 32 位还是 64 位不一致。在我的重写中,我尽可能使用 32 位。(除了cmp %rax, %rdxwrite 的返回值,制作 64 位似乎是个好主意,尽管我认为没有任何真正的理由。)


我的重写:

我删除了 call/ret 的东西,只是让它进入清理/退出而不是试图将它分成“函数”。

我还将缓冲区大小准确地更改为 64KiB,以 4k 页面对齐方式在堆栈上分配,并重新安排内容以简化和保存各处的指令。

还添加了# TODO关于短的评论。对于高达 64k 的管道写入,这似乎不会发生;Linux 只是在缓冲区有空间之前阻止写入,但是写入套接字可能会出现问题?或者可能只有更大的尺寸,或者如果像 SIGTSTP 或 SIGSTOP 这样的信号中断write()

#include <asm/unistd.h>
BUFSIZE = 1<<16

.section .text
.globl _start
_start:
    pop  %rax      # argc
    pop  %rdi
    pop  %rdi      # argv[1]
     # you'd only ever want to read args this way in _start, which isn't a function

    and  $-4096, %rsp           # round RSP down to a page boundary.
    sub  $BUFSIZE, %rsp         # reserve 64K buffer aligned by 4k

    dec  %eax      # if argc == 1,  then run with input fd = 0   (stdin)
    jz  .Luse_stdin

    # open argv[1]
    mov     $__NR_open, %eax 
    xor     %esi, %esi     # flags: 0 means read-only.
    xor     %edx, %edx     # mode unused without O_CREAT, but zero it out for peace of mind.
    syscall       # fd = open(argv[1], O_RDONLY)

.Luse_stdin:           # don't use stdin as a symbol name; stdio.h / libc also has one of type FILE*
    mov  %eax, %ebx     # save FD
    mov  %rsp, %rsi     # always read and write the same buffer
    jmp  .Lentry        # start with a read then EOF-check as loop condition
              # since we're now error-checking the write,
              # rotating the loop maybe wasn't helpful after all
              # and perhaps just read at the top so we can fall into it would work equally well

read_and_write:              # do {
    # print the file
    mov     %eax, %edx             # size = read_size
    mov     $__NR_write, %eax      # syscall #1 = write.
    mov     $1, %edi               # output fd always stdout
    #mov     %rsp, %rsi             # buf, done once outside loop
    syscall                        # write(1, buf, read_size)

    cmp     %rax, %rdx             # written size should match request
    jne     cleanup                 # TODO: handle short writes by calling again for the unwritten part of the buffer, e.g. add %rax, %rsi
                                    # but also check for write errors.
.Lentry:
     # read the file.
    mov    $__NR_read, %eax     # xor  %eax, %eax
    mov    %ebx, %edi           # input FD
   # mov    %rsp, %rsi           # done once outside loop
    mov    $BUFSIZE, %edx
    syscall                     # size = read(fd, buf, BUFSIZE)

    test   %eax, %eax
    jg     read_and_write    # }while(read_size > 0);   // until EOF or error
# any negative can be assumed to be an error, since we pass a size smaller than INT_MAX

cleanup:
# fd might be stdin which we don't want to close.
# just exit and let kernel take care of it, or check for fd==0
#    movl $__NR_close, %eax
#    movl %ebx, %edi 
#    syscall          # close (fd)  // return value ignored

exit:
    mov  %eax, %edi             # exit status = last syscall return value. read() = 0 means EOF, success.
    mov  $__NR_exit_group, %eax
    syscall                     # exit_group(status);

对于指令计数,perf stat --all-user ./asmcat /tmp/random > /dev/null显示它在用户空间中运行大约 47 条指令,而您的则为 57 条。(IIRC,perf 多计数了 1,所以我从测量结果中减去了它。)而且还有更多的错误检查,例如对于短写。

这只是 .text 部分中的 84 字节机器代码(而原始代码为 174 字节),并且我没有使用lea 1(%rsi), %eax(在将 RSI 归零之后)而不是mov $1, %eax. (或者mov %eax, %edi利用 _NR_write == STDIN_FILENO。)

我主要避免使用 R8..R15,因为它们需要 REX 前缀才能在机器代码中访问。

错误处理测试:

$ gcc -nostdlib -static asmcat.S -o asmcat            # build
$ cat /tmp/random | strace ./asmcat > /dev/full

execve("./asmcat", ["./asmcat"], 0x7ffde5e369d0 /* 55 vars */) = 0
read(0, "=head1 NAMEnn=for comment  Gener"..., 65536) = 65536
write(1, "=head1 NAMEnn=for comment  Gener"..., 65536) = -1 ENOSPC (No space left on device)
exit_group(-28)                         = ?
+++ exited with 228 +++
$ strace ./asmcat <&-      # close stdin
execve("./asmcat", ["./asmcat"], 0x7ffd0f5048c0 /* 55 vars */) = 0
read(0, 0x7ffc1b3ca000, 65536)          = -1 EBADF (Bad file descriptor)
exit_group(-9)                          = ?
+++ exited with 247 +++
$ strace ./asmcat /noexist
execve("./asmcat", ["./asmcat", "/noexist"], 0x7ffd429f1158 /* 55 vars */) = 0
open("/noexist", O_RDONLY)              = -1 ENOENT (No such file or directory)
read(-2, 0x7ffd4f296000, 65536)         = -1 EBADF (Bad file descriptor)
exit_group(-9)                          = ?
+++ exited with 247 +++

嗯,如果你想做错误处理,应该在打开后在 fd 上测试/jl。

  • @PeterEngelbert: I just updated with my rewrite, if you want to compare my choices against your rewrite. Re: kernel memory paging: https://www.kernel.org/doc/html/latest/admin-guide/mm/index.html is an overview / top-level of kernel docs for that very broad subject, including https://www.kernel.org/doc/html/latest/admin-guide/mm/concepts.html for the concepts overview. https://www.oreilly.com/library/view/system-performance-tuning/059600284X/ch04.html is a chapter of an *old* sysadmin book on tuning Linux systems

以上是通过x86-64汇编程序进行管道传输时的竞争条件的全部内容。
THE END
分享
二维码
< <上一篇
下一篇>>