記一次 splice 導致 io.Copy 阻塞的排查過程

記一次 splice 導致 io.Copy 阻塞的排查過程

簡而言之,net.TCPConn 的 ReadFrom 零拷貝實現 splice1.21.0 - 1.21.4 刪除了 SPLICE_F_NONBLOCK 參數,導致在 CentOS7.2(內核版本 3.10.0) 上 splice 被阻塞。

相關的 issuehttps://github.com/golang/go/issues/59041

這個問題在 1.21.5 中被修復,commithttps://github.com/yunginnanet/go/commit/35afad885d5e046a4a14643b5b530b128ca953de

背景

由於環境的問題,需要有一個 TCP 的代理,之前一直用 ncat -vl 10022 -k -c 'ncat -nv 127.0.0.1 22' 方式將 10022 端口的流量代理至 127.0.0.1:22,但是 ncat 是一個連接一個進程,如果要做短連接壓測的,代理會成爲瓶頸。

所以決定換個代理的軟件,因爲 Go 寫一個代理特別簡單,十行代碼就能實現一個性能不錯的服務,那就直接自己寫一個。

package main

import (
	"flag"
	"fmt"
	"io"
	"net"
	"sync"

	"github.com/sirupsen/logrus"
)

func main() {
	f := flag.String("from", "", "source addr")
	t := flag.String("to", "", "dest addr")
	flag.Parse()

	if *f == "" || *t == "" {
		fmt.Println("Invalid from/to address")
		return
	}
	logrus.WithFields(logrus.Fields{"from": *f, "to": *t}).Info("Setup proxy server")

	lis, err := net.Listen("tcp", *f)
	if err != nil {
		panic(err)
	}
	logrus.WithField("addr", lis.Addr()).Info("Listen on")

	for {
		conn, err := lis.Accept()
		if err != nil {
			panic(err)
		}
		go handleConn(conn, *t)
	}
}

func handleConn(uConn net.Conn, to string) {
	logrus.WithField("addr", uConn.RemoteAddr()).Info("New conn")
	defer uConn.Close()

	rConn, err := net.Dial("tcp", to)
	if err != nil {
		logrus.WithError(err).Error("Fail to net.DialTCP")
		return
	}
	logrus.WithField("local", rConn.LocalAddr()).Info("Start proxy conn")

	wg := sync.WaitGroup{}
	wg.Add(2)
	go func() {
		defer wg.Done()
		io.Copy(uConn, rConn)
		rConn.Close()
		uConn.Close()
	}()
	go func() {
		defer wg.Done()
		io.Copy(rConn, uConn)
		uConn.Close()
		rConn.Close()
	}()
	wg.Wait()
}

編譯操作系統爲 Debian12,Go 版本爲 1.21.1

因爲默認路由的原因,我把這個服務部署在了一個 CentOS7.2 的虛擬機裏面,壓測發現QPS總是上不去。

用 tcpdump 抓包定位到是這邊的代理程序有問題,流量沒有被正確的進行轉發。

爲避免出現敏感數據,用下面的圖來做模擬,在 A 使用 scpB 發送文件,中間經過了個我們寫的服務 PROXY

+----------------+      +-------------------------------+
|  (A) Debian12  |      |  (B) CentOS7.2                |
|                | <--> | 192.168.32.251:10022          |
| 192.168.32.251 |      |        └─> PROXY              |
|                |      |              └─> 127.0.0.1:22 |
+----------------+      +-------------------------------+

# 生成一個大的文件
#   dd if=/dev/zero of=/tmp/1.txt bs=1M count=1024
# 使用命令模擬壓測
#   scp -P 10022 /tmp/1.txt [email protected]:/tmp/

排查

ps 看到這個進程還在運行,所以不是進程退出導致的。

top 觀察進程 CPU 佔用也不高,所以不是代碼寫出死循環來了。

由於程序沒有加日誌,通過 strace -p $(pidof PROXY) 來分析一下當前哪些系統調用在執行,看起來是 epoll_pwait 沒有就緒事件返回。

[pid 26877] splice(14, NULL, 18, NULL, 1048576, 0) = -1 EAGAIN (Resource temporarily unavailable)
[pid 26790] epoll_pwait(5,  <unfinished ...>
[pid 26788] nanosleep({tv_sec=0, tv_nsec=20000},  <unfinished ...>
[pid 26790] <... epoll_pwait resumed>[], 128, 0, NULL, 0) = 0
[pid 26877] epoll_pwait(5,  <unfinished ...>
[pid 26790] epoll_pwait(5,  <unfinished ...>
[pid 26877] <... epoll_pwait resumed>[], 128, 0, NULL, 0) = 0
[pid 26877] futex(0xc000040d48, FUTEX_WAIT_PRIVATE, 0, NULL <unfinished ...>
[pid 26788] <... nanosleep resumed>NULL) = 0
[pid 26788] futex(0x5ea8a0, FUTEX_WAIT_PRIVATE, 0, {tv_sec=60, tv_nsec=0} <unfinished ...>
[pid 26790] <... epoll_pwait resumed>[{EPOLLIN|EPOLLOUT, {u32=2345140225, u64=9221451948300435457}}], 128, -1, NULL, 0) = 1
[pid 26790] epoll_pwait(5, [], 128, 0, NULL, 0) = 0
[pid 26790] epoll_pwait(5, [{EPOLLIN|EPOLLOUT, {u32=2345140225, u64=9221451948300435457}}], 128, -1, NULL, 0) = 1
         多條 epoll_pwait 省略

看看連接緩衝區裏面有沒有數據 netstat -ntp | grep 10022,在接受緩衝區內還有 1666120 個字節的數據沒有被讀出

tcp6  1666120      0 192.168.32.245:10022    192.168.32.251:49440    ESTABLISHED 26787/PROXY

當時想着看看重啓能不能復現,在重啓之前先 kill -3 把堆棧打印出來,拿到了一個關鍵的棧信息。

goroutine 19 [syscall]:
syscall.Syscall6(0x7ff92db08be8?, 0xc000068c88?, 0x45fca5?, 0xc000068c98?, 0x48ed3c?, 0xc000068cb0?, 0x48eea7?)
        /usr/local/go1.21/src/syscall/syscall_linux.go:91 +0x30 fp=0xc000068c60 sp=0xc000068bd8 pc=0x481b50
syscall.Splice(0xc000102000?, 0xc000068d08?, 0x0?, 0x4e70c0?, 0x4e70c0?, 0xc000068d20?)
        /usr/local/go1.21/src/syscall/zsyscall_linux_amd64.go:1356 +0x45 fp=0xc000068cc0 sp=0xc000068c60 pc=0x480d05
internal/poll.splice(...)
        /usr/local/go1.21/src/internal/poll/splice_linux.go:155
internal/poll.spliceDrain(0xc000102100?, 0xc000102000, 0x5a800?)
        /usr/local/go1.21/src/internal/poll/splice_linux.go:92 +0x185 fp=0xc000068d68 sp=0xc000068cc0 pc=0x4917c5
internal/poll.Splice(0x0?, 0x0?, 0x7fffffffffffffff)
        /usr/local/go1.21/src/internal/poll/splice_linux.go:42 +0x173 fp=0xc000068e00 sp=0xc000068d68 pc=0x491413
net.splice(0x0?, {0x53bca8?, 0xc000106000?})
        /usr/local/go1.21/src/net/splice_linux.go:39 +0xdf fp=0xc000068e60 sp=0xc000068e00 pc=0x4cc29f
net.(*TCPConn).readFrom(0xc000106008, {0x53bca8, 0xc000106000})
        /usr/local/go1.21/src/net/tcpsock_posix.go:48 +0x28 fp=0xc000068e90 sp=0xc000068e60 pc=0x4cd0c8
net.(*TCPConn).ReadFrom(0xc000106008, {0x53bca8?, 0xc000106000?})
        /usr/local/go1.21/src/net/tcpsock.go:130 +0x30 fp=0xc000068ed0 sp=0xc000068e90 pc=0x4cc770
io.copyBuffer({0x53bd68, 0xc000106008}, {0x53bca8, 0xc000106000}, {0x0, 0x0, 0x0})
        /usr/local/go1.21/src/io/io.go:416 +0x147 fp=0xc000068f50 sp=0xc000068ed0 pc=0x47d587
io.Copy(...)
        /usr/local/go1.21/src/io/io.go:389
main.handleConn.func2()
        /home/devel/demo/app/demo/main.go:73 +0xb2 fp=0xc000068fe0 sp=0xc000068f50 pc=0x4db672
runtime.goexit()
        /usr/local/go1.21/src/runtime/asm_amd64.s:1650 +0x1 fp=0xc000068fe8 sp=0xc000068fe0 pc=0x464641
created by main.handleConn in goroutine 17
        /home/devel/demo/app/demo/main.go:71 +0x368

分析看到在 io.Copy 這條路線有問題,先看看 io.Copy 的源碼

分析 io.Copy

io.Copy 內部有這麼一段代碼,優先於 read/write 調用,上面的堆棧打印看起來也是這個 ReadFrom 裏面有問題。

if wt, ok := src.(WriterTo); ok {
	return wt.WriteTo(dst)
}
// Similarly, if the writer has a ReadFrom method, use it to do the copy.
if rt, ok := dst.(ReaderFrom); ok {
	return rt.ReadFrom(src)
}

OK 先跳過這個 ReadFrom 看看能不能行呢,於是把 io.Copy 裏面的 WriteTo/ReadFrom 註釋,並且直接放到外面來,使用一般的 read/write 調用。

編譯運行,可行!!!

那麼問題就只能在這個 ReadFrom 裏面了,照着上面的堆棧,一路追到了 poll.Splice 內,但是之前沒有用過 splice 這個函數,只知道是一個零拷貝相關的函數。好吧,Go 在這裏還做了一些優化。

那看來還是得研究一下,這個 splice 系統調用。

分析 poll.Splice

在這之前先搜索了一些文檔看了一下,這個 splice文檔寫的相當好,很快就能夠理解。

文章裏面的的這張圖清晰的描述了兩次 splice 就能通過 pipe 在內核就將數據發送出去,沒有把數據從內核空間拷貝至用戶空間。

爲了減少語言的干擾,使用 C 照着 poll.Splice 重寫了一遍,代碼如下。在 splice_readfrom 內部,每次循環調用兩次 splice,一次將源 sockfd 的數據放至 pipe 中,一次將 pipe 中的數據寫入目的 sockfd 中。

#define _GNU_SOURCE 1
#include <arpa/inet.h>
#include <assert.h>
#include <errno.h>
#include <fcntl.h>
#include <inttypes.h>
#include <netinet/in.h>
#include <poll.h>
#include <pthread.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

#include <thread>

static ssize_t splice_drain(int fd, int pipefd, size_t max) {
  while (1) {
    ssize_t n = splice(fd, NULL, pipefd, NULL, max, 0);
    if (n >= 0)
      return n;

    // error handle
    if (errno == EINTR)
      continue;
    else if (errno != EAGAIN)
      return -1;
  }
}

static ssize_t splice_pump(int pipefd, int fd, size_t in_pipe) {
  ssize_t written = 0;
  while (in_pipe > 0) {
    ssize_t n = splice(pipefd, NULL, fd, NULL, in_pipe, 0);
    if (n >= 0) {
      in_pipe -= n;
      written += n;
      continue;
    }

    if (errno != EAGAIN)
      return -1;
  }
  return written;
}

static const size_t kMaxSpliceSize = 1 << 20;

ssize_t splice_readfrom(int dstfd, int srcfd) {
  int pipefd[2];
  if (pipe2(pipefd, 0) < 0)
    return -1;

  ssize_t written = 0;
  ssize_t remain = INT64_MAX;
  while (remain > 0) {
    size_t max = kMaxSpliceSize;
    if (max > (size_t)remain)
      max = remain;

    ssize_t in_pipe = splice_drain(srcfd, pipefd[1], max);
    if (in_pipe < 0)
      return -1;
    else if (in_pipe == 0)
      break;

    ssize_t n = splice_pump(pipefd[0], dstfd, in_pipe);
    if (n > 0) {
      remain -= n;
      written += n;
    }
  }
  close(pipefd[0]);
  close(pipefd[1]);
  return written;
}

int main(int argc, char **argv) {
  int sockfd = socket(AF_INET, SOCK_STREAM, 0);
  if (sockfd < 0) {
    perror("Fail to socket");
    return -1;
  }

  int opt = 1;
  setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, (const void *)&opt, sizeof(opt));

  fcntl(sockfd, F_SETFL, fcntl(sockfd, F_GETFL, NULL) | O_NONBLOCK);

  struct sockaddr_in addr;
  addr.sin_family = AF_INET;
  addr.sin_port = htons(10022);
  addr.sin_addr.s_addr = htonl(INADDR_ANY);

  if (bind(sockfd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
    perror("Fail to bind");
    return -1;
  }

  if (listen(sockfd, 10) < 0) {
    perror("Fail to listen");
    return -1;
  }
  printf("listen on\n");

  int timeout = 3000;
  struct pollfd fds = {sockfd};
  fds.events |= POLLIN;

  while (1) {
    int ret = poll(&fds, 1, timeout);
    if (ret > 0) {
      struct sockaddr_in in;
      socklen_t len = sizeof(in);
      int connfd = accept(sockfd, (struct sockaddr *)&in, &len);
      if (connfd < 0) {
        perror("Fail to accept");
        return -1;
      }

      fcntl(connfd, F_SETFL, fcntl(connfd, F_GETFL, NULL) | O_NONBLOCK);

      std::thread t(
          [](struct sockaddr_in addr, int u_connfd) {
            int sockfd = socket(AF_INET, SOCK_STREAM, 0);
            if (sockfd < 0) {
              perror("Fail to socket");
              return;
            }

            struct sockaddr_in dst_addr;
            dst_addr.sin_family = AF_INET;
            dst_addr.sin_port = htons(10022);
            dst_addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);

            if (connect(sockfd, (struct sockaddr *)&dst_addr,
                        sizeof(dst_addr)) < 0) {
              perror("Fail to connect");
              return;
            }

            char dst_txt[INET_ADDRSTRLEN];
            char src_txt[INET_ADDRSTRLEN];

            inet_ntop(AF_INET, &addr.sin_addr, src_txt, sizeof(src_txt));
            printf("New conn from %s:%d\n", src_txt, ntohs(addr.sin_port));

            std::thread t1([&]() { splice_readfrom(sockfd, u_connfd); });
            std::thread t2([&]() { splice_readfrom(u_connfd, sockfd); });
            t1.join();
            t2.join();

            close(sockfd);
            close(u_connfd);
          },
          in, connfd);
      t.detach();
    }
  }

  return 0;
}

測試下來,和 Go 版本表現一樣,也是被阻塞,不過現在問題就更清晰一些,splice 的使用有問題。

於是仔細看了一下文檔,裏面有一個參數 SPLICE_F_NONBLOCK,要不加上試一下看看,加上之後程序是正常運行的。

所以會是這個參數的問題?在 Go 的實現裏面,spliceflags 參數是爲 0 的,也就是意味着是沒有設置爲非阻塞狀態的。

想到我們之前的代理程序都沒有出現這個情況,難道是 Go 版本的原因?於是使用 Go1.18 對 PROXY 進行編譯運行,正常運行!

看了兩個版本的實現,果然 Go1.18 是含有這個 SPLICE_F_NONBLOCK 參數的,在之後的版本內被刪除了;繼續搜索,發現了有人提了個上面的 issue。

對代碼追蹤發現 受影響版本爲 1.21.0 - 1.21.4

擴展分析

issue 裏面 Go 的開發者說所有的 case 都正常能跑過,所以把這個參數刪除了。既然開發者測試沒有問題,但是實際使用又有問題,那就有可能是環境不一致導致的。

分析未在不同內核上splice表現不一致

在上面的排查過程中,我還把 PROXY(Go1.21.1) 放到 A 中運行,代理至 192.168.32.245:22 上,表現也是正常的。經過測試,io.Copy 在不同的系統上的影響如下:

Kernel\Go 1.18.0 1.21.1
3.10 正常 不正常
6.1 正常 正常

1.18 是沒有 BUG 的版本,也就是增加了 SPLICE_F_NONBLOCK 參數。那爲何 1.21.1 版本沒有增加這個參數的可以在 6.1 的內核上運行呢。

沒有很好的頭緒,難道是 pipe 導致的問題嗎,pipe 太小了?於是調整 pipe 大小

fcntl(pipefd[0], F_SETPIPE_SZ, 1 << 20);
fcntl(pipefd[1], F_SETPIPE_SZ, 1 << 20);

使用 Go1.21.1 版本進行編譯,並且進行測試,結果如下:

Kernel\Go 1.21.1
3.10 正常
6.1 正常

pipe 太小,那測試數據小於默認大小 65536 的看看會不會有問題

dd if=/dev/zero of=/tmp/1.txt bs=1 count=65536

測試結果如下:

測試數據大小 測試結果
65536 不正常
32768 不正常
25000 不正常
16384 正常

splice 還有一個參數 len,爲從 fd_in 到 pipe_w 中的字節數,如果我減少這個大小,那麼結果會如何。測試下來 和調整 pipe 大小帶來的結果相同

splice 在不同內核上表現的結果不同這個問題,可以縮小一些排查的範圍了:和 pipe 相關

不同內核的 splice 實現

看代碼之前確認要關注的點:在哪裏存在阻塞的動作

splice 實現位於 fs/splice.c 中,下面的代碼取自 kernel-6.1(3.10 的內核代碼也相似,主體邏輯沒有變化)

SYSCALL_DEFINE6(splice, ...) // fs/splice.c
  -> __do_splice
    -> do_splice
      -> splice_file_to_pipe  // 將 sockfd 的數據傳輸至 pipe 中,走這條路徑
        -> do_splice_to
          -> tcp_splice_read (in->f_op->splice_read) // net/ipv4/tcp.c
            -> __tcp_splice_read
              -> tcp_read_sock
                -> tcp_splice_data_recv
                  -> skb_splice_bits                 // net/core/ipv4/skbuff.c
                    -> splice_to_pipe                // fs/splice.c

經過 TCP 的讀取,兜兜轉轉又回到 fs/splice.c 中。

kernel-6.1 的實現

在 kernel-6.1 的實現中,spclie_to_pipe 的實現沒有阻塞

ssize_t splice_to_pipe(struct pipe_inode_info *pipe,
		       struct splice_pipe_desc *spd)
{
  // ....
	while (!pipe_full(head, tail, pipe->max_usage)) {
		struct pipe_buffer *buf = &pipe->bufs[head & mask];

		buf->page = spd->pages[page_nr];
		buf->offset = spd->partial[page_nr].offset;
		buf->len = spd->partial[page_nr].len;
		buf->private = spd->partial[page_nr].private;
		buf->ops = spd->ops;
		buf->flags = 0;

		head++;
		pipe->head = head;
		page_nr++;
		ret += buf->len;

		if (!--spd->nr_pages)
			break;
	}
	if (!ret)
		ret = -EAGAIN;

out:
	while (page_nr < spd_pages)
		spd->spd_release(spd, page_nr++);

	return ret;
}

向上回溯,在 splice_file_to_pipe 中,wait_for_space 中如果 pipe 滿了則進行等待 pipe_wait_writable(pipe)

long splice_file_to_pipe(struct file *in,
			 struct pipe_inode_info *opipe,
			 loff_t *offset,
			 size_t len, unsigned int flags)
{
	long ret;

	pipe_lock(opipe);
	ret = wait_for_space(opipe, flags);
	if (!ret)
		ret = do_splice_to(in, offset, opipe, len, flags);
	pipe_unlock(opipe);
	if (ret > 0)
		wakeup_pipe_readers(opipe);
	return ret;
}

static int wait_for_space(struct pipe_inode_info *pipe, unsigned flags)
{
	for (;;) {
		if (unlikely(!pipe->readers)) {
			send_sig(SIGPIPE, current, 0);
			return -EPIPE;
		}
		if (!pipe_full(pipe->head, pipe->tail, pipe->max_usage))
			return 0;
		if (flags & SPLICE_F_NONBLOCK)
			return -EAGAIN;
		if (signal_pending(current))
			return -ERESTARTSYS;
		pipe_wait_writable(pipe);
	}
}

調整測試代碼,對 pipe 只生產而不不消費數據。

ssize_t splice_readfrom(int dstfd, int srcfd) {
    ...
    ssize_t in_pipe = splice_drain(srcfd, pipefd[1], max);

    sleep(1);
    written += in_pipe;
    printf("+%ld written=%ld\n", in_pipe, written);
    continue;

    ssize_t n = splice_pump(pipefd[0], dstfd, in_pipe);
}

爲避免 ssh 的元數據干擾,不再使用 sshd 127.0.0.1:22 作爲最後點,轉而寫了一個 io.Discard 的 Go 服務。
測試客戶端爲 ncat -nv 192.168.32.251 10022 < /tmp/1.txt

在 Debian12(kernel-6.1) 上進行測試,結果如下

+65509 written=65509
+57344 written=122853
+49152 written=172005
+36864 written=208869
+28672 written=237541
+20480 written=258021
+16384 written=274405
+8192 written=282597
+4096 written=286693
// 之後阻塞

pipe 的大小爲 65536(PAGE_SIZE * 16),但是寫入的數據大於了 pipe 的緩衝區後,還能夠繼續寫入,這點和可能和 skbuff/pipe 的 PAGE 有關,這裏先跳過,直接測試一下在 CentOS7.2 上表現如何,結果直接阻塞,第一個 splice 都沒有返回,好吧看看代碼。

kernel-3.10 的實現

同樣找到關鍵的 splice_to_pipe 函數

ssize_t splice_to_pipe(struct pipe_inode_info *pipe, struct splice_pipe_desc *spd)
{
  // ...
	for (;;) {
		if (pipe->nrbufs < pipe->buffers) {
			int newbuf = (pipe->curbuf + pipe->nrbufs) & (pipe->buffers - 1);
			struct pipe_buffer *buf = pipe->bufs + newbuf;

			buf->page = spd->pages[page_nr];
			buf->offset = spd->partial[page_nr].offset;
			buf->len = spd->partial[page_nr].len;
			buf->private = spd->partial[page_nr].private;
			buf->ops = spd->ops;
			if (spd->flags & SPLICE_F_GIFT)
				buf->flags |= PIPE_BUF_FLAG_GIFT;

			pipe->nrbufs++;
			page_nr++;
			ret += buf->len;

			if (!--spd->nr_pages)
				break;
			if (pipe->nrbufs < pipe->buffers)
				continue;

			break;
		}

		if (spd->flags & SPLICE_F_NONBLOCK) {
			if (!ret)
				ret = -EAGAIN;
			break;
		}

		pipe->waiting_writers++;
		pipe_wait(pipe);
		pipe->waiting_writers--;
	}
	return ret;
}

代碼刪除了和信號相關的邏輯,整個循環內的關鍵路徑

  • if (!--spd->nr_pages) 爲數據頁都被掛在 pipe 後退出循環
  • if (pipe->nrbufs < pipe->buffers) 爲 pipe 中還有空間則繼續運行
  • if (spd->flags & SPLICE_F_NONBLOCK) 爲 pipe 沒有空間但是設置了非阻塞,則直接返回
  • pipe_wait 爲數據沒有讀完,但是 pipe 已經沒有空間則直接被掛起

在上面分析未在不同內核上splice表現不一致的結果中,可以看到 16K 的數據是能夠返回的,數據的大小大一些就被阻塞了。

對比分析

kernel-6.1 對 splice 的實現相較 kernel-3.10 做了關鍵的兩點變化:

  1. 提前做了 pipe 的空判斷,這樣數據掛載函數 splice_to_pipe 內部就不用進行阻塞了,而 3.10 將空判斷和數據的轉移放在一起做了
    static int wait_for_space(struct pipe_inode_info *pipe, unsigned flags)
    {
    	for (;;) {
    		if (unlikely(!pipe->readers)) {
    			send_sig(SIGPIPE, current, 0);
    			return -EPIPE;
    		}
    		if (!pipe_full(pipe->head, pipe->tail, pipe->max_usage))
    			return 0;
    		if (flags & SPLICE_F_NONBLOCK)
    			return -EAGAIN;
    		if (signal_pending(current))
    			return -ERESTARTSYS;
    		pipe_wait_writable(pipe);
    	}
    }
    
  2. 限制了單次 splice 讀取的大小
    static long do_splice_to(struct file *in, loff_t *ppos,
    			 struct pipe_inode_info *pipe, size_t len,
    			 unsigned int flags)
    {
    	/* Don't try to read more the pipe has space for. */
    	p_space = pipe->max_usage - pipe_occupancy(pipe->head, pipe->tail);
    	len = min_t(size_t, len, p_space << PAGE_SHIFT);
    
    	return in->f_op->splice_read(in, ppos, pipe, len, flags);
    }
    

結合代碼和測試程序進行分析一下
kernel-3.10 裏面的實現可能在 splice_to_pipe 中就被阻塞了,pipe 可容納的空間小於 skbuff 中的數據
kernel-6.1 由於每次都會判斷是否爲空,只向 pipe 中寫入可容納的數據,所以只要有空間就不會被阻塞。

那麼就遺留另外一個問題,pipe 的可容納大小在不同版本內核上的不一樣,和文檔裏面的 65536 都有一些明顯出入,但是測試 pipe 的 write,則是準確的 65536. 據查資料得到的結論,fd -> pipe -> fd 這個過程只是 skbuff 的 PAGE 變化,內核不會再進行額外的內存分配。

上面的分析還需要通過調試來進行證明,那可以再寫一篇文章通過kprobe分析 splice 了,這裏再挖一個坑。

結論

這個問題只在低版本的內核上有問題,在高版本 Debian12 是正常的,在 Go1.21.5 中已經修復,建議使用 Go1.21.5 及以上的版本。

TODO

通過 kprobe 來分析 splice 下的 pipe 空間變化

參考

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章