進程間如何傳遞 f

本文會從文件描述符的定義講到它的原理,然後講解如何在進程之間傳遞文件描述符,其實使用的還是 unix domain socket,在 unix domain socket in Go 詳解 + 示例大集合中關注的是 unix domain socket 的用法和性能,本次使用它能傳遞文件描述符這個不常見的控制數據,最後會使用 Go 實現一個優雅退出的例子,雖然看到最後你可能覺得不夠優雅。

什麼是 fd

大家肯定多少的知道什麼是 fd(file describer),當調用 open 系統調用的時候會返回 int 類型的值,這個值既可以理解爲是文件的 ID,也可理解爲操作文件的句柄(當然理解爲後者更好,前者類比的不恰當)。

1、 文件描述符到底是進程級別還是系統級別?

2、 文件描述符和 inode 之間的關係?

我們先來回答 “文件描述符到底是進程級別還是系統級別?”,我們可以看一個例子:

func main() {
  f1, _ := os.Open("/root/go/a.txt")
  f2, _ := os.Open("/root/go/a.txt")
  fmt.Println(int(f1.Fd()), f2.Fd())
  time.Sleep(time.Second * 60 * 50)
}

我們同時啓動兩次來看看分別輸出什麼,都是輸出的 3 5,我們看到打開第一個文件輸出的 3,自然的想到前面的 0、1、2 去哪了?然後在回憶一下我們要把一個命令的輸出 / 錯誤重定向到一個文件中怎麼搞?

是不是使用形如command 2>a.log這樣的命令。所以這就引出了三個全局的文件描述符(除了他們都是進程級別的了):

dnp3Zn

我們再把這個問題深挖一下,fd 怎麼對應到真實的文件的呢?,首先需要科普一下 Linux 文件系統的結構 (圖 from introduction-to-the-linux-virtual-filesystem-vfs):

VFS 的存在相當於爲上層提供統一的接口,下層能對接任意的存儲,比如 ext 系列、nfs、xfs 等。inode 存放的是文件的 metadata,比如權限,大小是多大,佔多少個塊等,這個結構相當大。

VFS 還要具有快速通過文件名得到 inode 的能力,總不能把所有的 inode 都緩存下來吧,這誰也受不了了呀,所以就有了 dentry(directory entry),他是一個承上啓下的結構,能通過文件名快速得到 inode,VFS 會把儘量多的 dentry 緩存下來,即 dentry cache。

現在我們對文件系統有了初步的認識,我們在回過頭來看_應用拿到的 fd 和具體負責存儲的 inode 之間的關係_,看下圖(from 文件描述符(File Descriptor)簡介):

進程 A 中的 fd 1 和 fd 30 都指向 global file table 中的 23 號,這是在同一個進程中 open 多次的結果;

進程 A 中 fd 0 指向 global file table 中的 0 號文件句柄和進程 B 中的 fd3 指向的 global file table 中的 86 號文件句柄,它們(global file table 中的 0 號和 86 號)都指向 i-node 表中的 1076 號 entry;

進程 A 中 fd 2 和進程 B 中的 fd 2 都指向的 global file table 中的 73 號文件句柄,這可能有兩種情況:A 和 B 是父子進程關係,子進程默認繼承父繼承的所有 fd;A 和 B 通過 unix domain socket 把這個進程傳遞。

我們通過 open 拿到的是一個指針,指向的是全局由 kernel 維護的 file table,這個裏面記錄着文件以什麼 mode 被打開以及指向的 inode。

如何在進程間傳遞 fd

像 nginx、envoy 重啓的時候都是會先 fork 一個子進程出來,然後這個子進程默認繼承了父進程的文件描述符,老的 worker 處理之後再退出,新 worker 處理新流量。

但是如果子進程要和父進程通信,或者兩個不相關的進程傳遞 fd 就需要採用 IPC,將文件描述符的指針傳遞過去了。read/write 這種最基本的 I/O 函數解決不了傳遞控制數據(control messages、ancillary data),這個時候 recv/send 和 recvmsg/sendmsg 就呼之欲出了,這裏我們只介紹 recvmsg/sendmsg,因爲它是 recv/send 加強版,能夠給內核傳遞的參數更多。我們主要來看下功能最強大的 sendmsg(send、send 以及 sendmsg 之間的差別可以看 recv(2) 和 send(2))。

ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags);
ssize_t recvmsg(int sockfd, struct msghdr *msg, int flags);

主要來看 msghdr 這個結構:

struct msghdr {
   void         *msg_name; /* Optional address */
   socklen_t     msg_namelen; /* Size of address */
   struct iovec *msg_iov;        /* Scatter/gather array */
   size_t        msg_iovlen; /* # elements in msg_iov */
   void         *msg_control; /* Ancillary data, see below */
   size_t        msg_controllen; /* Ancillary data buffer len */
   int           msg_flags; /* Flags (unused) */
};

能夠通過 msg_control 傳遞不常見的協議頭、文件描述符以及 unix 認證信息等(老版本的叫 msg_accrights)。這裏就不過多說,有興趣的請看《unix 網絡編程卷 1: API》的 14.5 章,講的很詳細。

現在我們知道通過 socket 傳遞不僅能傳遞 payload 數據還能傳遞附加數據(也叫控制數據),現在我們能寫代碼開搞了。

用 Go 實現進程間傳遞 fd

我們實現如下:

  1. 進程 A 監聽 9087 端口對外提供服務

  2. 進程 B 監聽 / tmp/sock.sock 文件,接受其他進程(這裏只有進程 A)傳遞過來已經 listen 文件描述符,繼續進行 accept 處理

  3. 進程 A 監聽 Interrupt 信號,收到信號將監聽 9087 端口的文件描述符傳遞給 B 進程,然後退出

  4. 最後 B 進程監聽 9087 端口對外提供服務

我們先看一下執行流程:

1、 啓動 a 程序, 並確認

lsof -i:9087
COMMAND   PID   USER   FD   TYPE             DEVICE SIZE/OFF NODE NAME
a       82703 helios    5u  IPv6 0xd805aa2d675a920f      0t0  TCP *:9087 (LISTEN)

2、 啓動 b 程序

3、 通過 nc localhost 9087 和 a 程序溝通

nc localhost 9087
aaa
a process response
aaa
a process respons

4、通過 kill -2 a 進程的 pid,觸發鏈接遷移,能觀察到兩個現象:步驟三的鏈接斷了以及 9087 是由 b 程序監聽的了,如下:

COMMAND   PID   USER   FD   TYPE             DEVICE SIZE/OFF NODE NAME
b       82725 helios    8u  IPv6 0xd805aa2d675a920f      0t0  TCP *:9087 (LISTEN)
b       82725 helios    9u  IPv6 0xd805aa2d675a920f      0t0  TCP *:9087 (LISTEN)

5、 再通過 nc localhost 9087 就是和 b 程序進行溝通了

nc localhost 9087
aaa
b process response
aaa
b process respons

我們先來看下 a 的代碼:

const sock = "/tmp/sock.sock"
func main() {
  l, _ := net.Listen("tcp", ":9087")
  defer l.Close()
  // 監聽信號
  ch := make(chan os.Signal, 1)
  signal.Notify(ch, os.Interrupt)
  // 開啓遷移鏈接的goroutine
  go transferConn(ch, l.(*net.TCPListener))
  for {
    c, err := l.Accept()
    if err != nil {
      log.Println(err)
      break
    }
    go func() {
      for {
        var buf = make([]byte, 1)
        c.Read(buf)
        c.Write([]byte("a process response\n"))
      }
    }()

  }
}

func transferConn(ch <-chan os.Signal, l *net.TCPListener) {
  <-ch
  log.Print("start transfer listener")
  c,err := net.Dial("unix", sock)
  
  unixConn := c.(*net.UnixConn)
  linstenFile, err := l.File()

  rights := syscall.UnixRights(int(linstenFile.Fd()))
  var buf = make([]byte, 1)
  
  unixConn.WriteMsgUnix(buf, rights, nil)
  fmt.Println("ending transfer listener")
  l.Close()
}

syscall.UnixRights 就是相當於填充的msghdr.msg_control字段。

再來看看 b 程序:

const sock = "/tmp/sock.sock"
func main() {
  _ = syscall.Unlink(sock)
  l , _ := net.Listen("unix", sock)
  log.Print("listen " + sock)
  defer l.Close()
  recvListener(l.(*net.UnixListener))
}

func recvListener(unixListener *net.UnixListener) {
  unixConn, _ := unixListener.AcceptUnix()
  defer unixConn.Close()
  var (
    buf = make([]byte, 1)
    oob = make([]byte, 1024)
  )
  _, oobn, _, _, _ := unixConn.ReadMsgUnix(buf, oob)
  scms, _ := unix.ParseSocketControlMessage(oob[0:oobn])

  recvFds, _ := unix.ParseUnixRights(&scms[0])

  recvFile := os.NewFile(uintptr(recvFds[0]), "")

  recvFileListener, _ := net.FileListener(recvFile)

  recvListener := recvFileListener.(*net.TCPListener)

  for {
    conn, _ := recvListener.Accept()
    go func() {
      for {
        var buf = make([]byte, 1)
        conn.Read(buf)
        conn.Write([]byte("b process response\n"))
      }
    }()

  }
}

其實看下來挺清晰的,屬於不看不知道一看就會的那種。

總結

看懂了本篇文章,相信你對優雅退出也就不陌生了,但是這也是我開篇說的不夠優雅的地方,因爲這個過程是要斷連的,並沒有對已經建立的連接遷移的過程,其實每個連接也是一個 fd,但是要處理讀寫關係就比較負責,但也不是不能做。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/ts_qsUee6mUFv0FpykaOXQ