首先,必须声明,“进程间传递文件描述符”这个说法是错误的。

在处理文件时,内核空间和用户空间使用的主要对象是不同的。对用户程序来说,一个文件由一个文件描述符标识。该描述符是一个整数,在所有有关文件的操作中用作标识文件的参数。文件描述符是在打开文件时由内核分配,只在一个进程内部有效。两个不同进程可以使用同样的文件描述符,但二者并不指向同一个文件。基于同一个描述符来共享文件是不可能的。
《深入理解 Linux 内核架构》

这里说的“进程间传递文件描述符”是指,A 进程打开文件 fileA,获得文件描述符为 fdA,现在 A 进程要通过某种方法,根据 fdA,使得另一个进程 B,获得一个新的文件描述符 fdB,这个 fdB 在进程 B 中的作用,跟 fdA 在进程 A 中的作用一样。即在 fdB 上的操作,即是对 fileA 的操作。

这看似不可能的操作,是怎么进行的呢?

答案是使用匿名Unix域套接字,即socketpair()和sendmsg/recvmsg来实现。

关于 socketpair

UNIX domain sockets provide both stream and datagram interfaces. The UNIX
domain datagram service is reliable, however. Messages are neither lost nor delivered
out of order. UNIX domain sockets are like a cross between sockets and pipes. You can use the network-oriented socket interfaces with them, or you can use the socketpair
function to create a pair of unnamed, connected, UNIX domain sockets.
APUE 3rd edition,17.2

socketpair的原型为:

#include <sys/types.h>  
#include <sys/socket.h>  
  
int socketpair(int d, int type, int protocol, int sv[2]);  

传入的参数 sv 为一个整型数组,有两个元素。当调用成功后,这个数组的两个元素即为 2 个文件描述符。

一对连接起来的 Unix 匿名域套接字就建立起来了,它们就像一个全双工的管道,每一端都既可读也可写。

ipc-1.jpg

即,往 sv[0] 写入的数据,可以通过 sv[1] 读出来,往 sv[1] 写入的数据,也可以通过 sv[0] 读出来。

关于 sendmsg/recvmsg

通过 socket 发送数据,主要有三组系统调用,分别是:

  1. send/recv(与 write/read 类似,面向连接的)
  2. sendto/recvfrom(sendto 与 send 的差别在于,sendto 可以面向无连接,recvfrom 与 recv 的区别是,recvfrom 可以获取 sender 方的地址)
  3. sendmsg/recvmsg。通过 sendmsg,可以用 msghdr 参数,来指定多个缓冲区来发送数据,与writev系统调用类似。

sendmsg函数原型如下:

#include <sys/socket.h>  
ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags);  

其中,根据 POSIX.1 msghdr 的定义至少应该包含下面几个成员:

struct msghdr {  
    void *msg_name; /* optional address */  
    socklen_t msg_namelen; /* address size in bytes */  
    struct iovec *msg_iov; /* array of I/O buffers */  
    int msg_iovlen; /* number of elements in array */  
    void *msg_control; /* ancillary data */  
    socklen_t msg_controllen; /* number of ancillary bytes */  
    int msg_flags; /* flags for received message */  
};

在 Linux 的 manual page 中,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 */  
    socklen_t     msg_controllen; /* ancillary data buffer len */  
    int           msg_flags;      /* flags on received message */  
};

查看 Linux 内核源代码(3.18.1),可知 msghdr 的准确定义为:

struct msghdr {  
	void		*msg_name;	/* ptr to socket address structure */  
	int		msg_namelen;	/* size of socket address structure */  
	struct iovec	*msg_iov;	/* scatter/gather array */  
	__kernel_size_t	msg_iovlen;	/* # elements in msg_iov */  
	void		*msg_control;	/* ancillary data */  
	__kernel_size_t	msg_controllen;	/* ancillary data buffer length */  
	unsigned int	msg_flags;	/* flags on received message */  
};  
  

可见,与 Manual paga 中的描述一致。

其中,前两个成员 msg_name 和 msg_namelen 是用来在发送 datagram 时,指定目的地址的。如果是面向连接的,这两个成员变量可以不用。

接下来的两个成员,msg_iov 和 msg_iovlen,则是用来指定发送缓冲区数组的。其中,msg_iovlen 是 iovec 类型的元素的个数。每一个缓冲区的起始地址和大小由 iovec 类型自包含,iovec 的定义为:

struct iovec {  
    void *iov_base;   /* Starting address */  
    size_t iov_len;   /* Number of bytes */  
};  

成员 msg_flags 用来描述接受到的消息的性质,由调用 recvmsg 时传入的 flags 参数设置。recvmsg 的函数原型为:

#include <sys/socket.h>  
ssize_t recvmsg(int sockfd, struct msghdr *msg, int flags);  

与 sendmsg 相对应,recvmsg 用 msghdr 结构指定多个缓冲区来存放读取到的结果。flags 参数用来修改 recvmsg 的默认行为。传入的 flags 参数在调用完 recvmsg 后,会被设置到 msg 所指向的msghdr 类型的 msg_flags 变量中。flags可以为如下值:

ipc-2.jpg

回来继续讲 sendmsg 和 msghdr 结构。

msghdr 结构中剩下的两个成员,msg_control 和 msg_contorllen,是用来发送或接收控制信息的。其中,msg_control 指向一个 cmsghdr 的结构体,msg_controllen 成员是控制信息所占用的字节数。

注意,msg_controllen 与前面的 msg_iovlen 不同,msg_iovlen 是指的由成员 msg_iov 所指向的 iovec 型的数组的元素个数,而 msg_controllen,则是所有控制信息所占用的总的字节数。

其实,msg_control 也可能是个数组,但 msg_controllen 并不是该 cmsghdr 类型的数组的元素的个数。在 Manual page 中,关于 msg_controllen 有这么一段描述:

To create ancillary data, first initialize the msg_controllen member of the msghdr with the length of the control message buffer. Use CMSG_FIRSTHDR() on the msghdr to get the first control message and CMSG_NEXTHDR to get all subsequent ones. In each control message, initialize cmsg_len (with CMSG_LEN), the other cmsghdr header fields, and the data portion using CMSG_DATA. Finally, the msg_controllen field of the msghdr should be set to the sum of the CMSG_SPACE() of the length of all control messages in the buffer.

在 Linux 的 Manual page(man cmsg)中,cmsghdr 的定义为:

struct cmsghdr {  
    socklen_t   cmsg_len;   /* data byte count, including header */  
    int         cmsg_level; /* originating protocol */  
    int         cmsg_type;  /* protocol-specific type */  
    /* followed by  unsigned char   cmsg_data[]; */  
};  

注意,控制信息的数据部分,是直接存储在cmsg_type之后的。但中间可能有一些由于对齐产生的填充字节,由于这些填充数据的存在,对于这些控制数据的访问,必须使用Linux提供的一些专用宏来完成。这些宏包括如下几个:

#include <sys/socket.h>  
  
struct cmsghdr *CMSG_FIRSTHDR(struct msghdr *msgh);  
struct cmsghdr *CMSG_NXTHDR(struct msghdr *msgh, struct cmsghdr *cmsg);  
size_t CMSG_ALIGN(size_t length);  
size_t CMSG_SPACE(size_t length);  
size_t CMSG_LEN(size_t length);  
unsigned char *CMSG_DATA(struct cmsghdr *cmsg);  

其中:

  • CMSG_FIRSTHDR() 返回 msgh 所指向的 msghdr 类型的缓冲区中的第一个 cmsghdr 结构体的指针。
  • CMSG_NXTHDR() 返回传入的 cmsghdr 类型的指针的下一个 cmsghdr 结构体的指针。
  • CMSG_ALIGN() 根据传入的 length大小 ,返回一个包含了添加对齐作用的填充数据后的大小。
  • CMSG_SPACE() 中传入的参数 length 指的是一个控制信息元素(即一个 cmsghdr 结构体)后面数据部分的字节数,返回的是这个控制信息的总的字节数,即包含了头部(即 cmsghdr 各成员)、数据部分和填充数据的总和。
  • CMSG_DATA() 根据传入的 cmsghdr 指针参数,返回其后面数据部分的指针。
  • CMSG_LEN() 传入的参数是一个控制信息中的数据部分的大小,返回的是这个根据这个数据部分大小,需要配置的 cmsghdr 结构体中 cmsg_len 成员的值。这个大小将为对齐添加的填充数据也包含在内。

用一张图来表示这几个变量和宏的关系为:

ipc-3.jpg

如前所述,msghdr 结构中,msg_controllen 成员的大小为所有 cmsghdr 控制元素调用 CMSG_SPACE() 后相加的和。

讲了这么多 msghdr、cmsghdr,还是没有讲到如何传递文件描述符。其实很简单,本来 sendmsg 是和 send 一样,是用来传送数据的,只不过其数据部分的 buffer 由参数 msg_iov 来指定,至此,其行为和 send 可以说是类似的。

但是 sendmsg 提供了可以传递控制信息的功能,我们要实现的传递描述符这一功能,就必须要用到这个控制信息。在 msghdr 变量的 cmsghdr 成员中,由控制头 cmsg_level 和 cmsg_type 来设置“传递文件描述符”这一属性,并将要传递的文件描述符作为数据部分,保存在 cmsghdr 变量的后面。这样就可以实现传递文件描述符这一功能,在此时,是不需要使用 msg_iov 来传递数据的。

具体的说,为 msghdr 的成员 msg_control 分配一个 cmsghdr 的空间,将该 cmsghdr 结构的 cmsg_level 设置为 SOL_SOCKET,cmsg_type 设置为 SCM_RIGHTS,并将要传递的文件描述符作为数据部分,调用 sendmsg 即可。其中,SCM 表示 socket-level control message,SCM_RIGHTS表示我们要传递访问权限。

弄清楚了发送部分,文件描述符的接收部分就好说了。跟发送部分一样,为控制信息配置好属性,并在其后分配一个文件描述符的数据部分后,在成功调用 recvmsg 后,控制信息的数据部分就是在接收进程中的新的文件描述符了,接收进程可直接对该文件描述符进行操作。

Nginx 中传递文件描述符的代码实现

关于如何在进程间传递文件描述符,我们已经理的差不多了。下面看看 Nginx 中是如何做的。

Nginx 中发送文件描述符的相关代码为:

ngx_int_t  
ngx_write_channel(ngx_socket_t s, ngx_channel_t *ch, size_t size,  
    ngx_log_t *log)  
{  
    ssize_t             n;  
    ngx_err_t           err;  
    struct iovec        iov[1];  
    struct msghdr       msg;  
  
#if (NGX_HAVE_MSGHDR_MSG_CONTROL)  
  
    union {  
        struct cmsghdr  cm;  
        char            space[CMSG_SPACE(sizeof(int))];  
    } cmsg;  
  
    if (ch->fd == -1) {  
        msg.msg_control = NULL;  
        msg.msg_controllen = 0;  
  
    } else {  
        msg.msg_control = (caddr_t) &cmsg;  
        msg.msg_controllen = sizeof(cmsg);  
  
        ngx_memzero(&cmsg, sizeof(cmsg));  
  
        cmsg.cm.cmsg_len = CMSG_LEN(sizeof(int));  
        cmsg.cm.cmsg_level = SOL_SOCKET;  
        cmsg.cm.cmsg_type = SCM_RIGHTS;  
  
        /*  
         * We have to use ngx_memcpy() instead of simple  
         *   *(int *) CMSG_DATA(&cmsg.cm) = ch->fd;  
         * because some gcc 4.4 with -O2/3/s optimization issues the warning:  
         *   dereferencing type-punned pointer will break strict-aliasing rules  
         *  
         * Fortunately, gcc with -O1 compiles this ngx_memcpy()  
         * in the same simple assignment as in the code above  
         */  
  
        ngx_memcpy(CMSG_DATA(&cmsg.cm), &ch->fd, sizeof(int));  
    }  
  
    msg.msg_flags = 0;  
  
#else  
  
    if (ch->fd == -1) {  
        msg.msg_accrights = NULL;  
        msg.msg_accrightslen = 0;  
  
    } else {  
        msg.msg_accrights = (caddr_t) &ch->fd;  
        msg.msg_accrightslen = sizeof(int);  
    }  
  
#endif  
  
    iov[0].iov_base = (char *) ch;  
    iov[0].iov_len = size;  
  
    msg.msg_name = NULL;  
    msg.msg_namelen = 0;  
    msg.msg_iov = iov;  
    msg.msg_iovlen = 1;  
  
    n = sendmsg(s, &msg, 0);  
  
    if (n == -1) {  
        err = ngx_errno;  
        if (err == NGX_EAGAIN) {  
            return NGX_AGAIN;  
        }  
  
        ngx_log_error(NGX_LOG_ALERT, log, err, "sendmsg() failed");  
        return NGX_ERROR;  
    }  
  
    return NGX_OK;  
}  

其中,参数 s 就是一个用 socketpair 创建的管道的一端,要传送的文件描述符位于参数 ch 所指向的结构体中。ch 结构体本身,包含要传送的文件描述符和其他成员,则通过 io_vec 类型的成员 msg_iov 传送。

接收部分的代码为:

ngx_int_t  
ngx_read_channel(ngx_socket_t s, ngx_channel_t *ch, size_t size, ngx_log_t *log)  
{  
    ssize_t             n;  
    ngx_err_t           err;  
    struct iovec        iov[1];  
    struct msghdr       msg;  
  
#if (NGX_HAVE_MSGHDR_MSG_CONTROL)  
    union {  
        struct cmsghdr  cm;  
        char            space[CMSG_SPACE(sizeof(int))];  
    } cmsg;  
#else  
    int                 fd;  
#endif  
  
    iov[0].iov_base = (char *) ch;  
    iov[0].iov_len = size;  
  
    msg.msg_name = NULL;  
    msg.msg_namelen = 0;  
    msg.msg_iov = iov;  
    msg.msg_iovlen = 1;  
  
#if (NGX_HAVE_MSGHDR_MSG_CONTROL)  
    msg.msg_control = (caddr_t) &cmsg;  
    msg.msg_controllen = sizeof(cmsg);  
#else  
    msg.msg_accrights = (caddr_t) &fd;  
    msg.msg_accrightslen = sizeof(int);  
#endif  
  
    n = recvmsg(s, &msg, 0);  
  
    if (n == -1) {  
        err = ngx_errno;  
        if (err == NGX_EAGAIN) {  
            return NGX_AGAIN;  
        }  
  
        ngx_log_error(NGX_LOG_ALERT, log, err, "recvmsg() failed");  
        return NGX_ERROR;  
    }  
  
    if (n == 0) {  
        ngx_log_debug0(NGX_LOG_DEBUG_CORE, log, 0, "recvmsg() returned zero");  
        return NGX_ERROR;  
    }  
  
    if ((size_t) n < sizeof(ngx_channel_t)) {  
        ngx_log_error(NGX_LOG_ALERT, log, 0,  
                      "recvmsg() returned not enough data: %z", n);  
        return NGX_ERROR;  
    }  
  
#if (NGX_HAVE_MSGHDR_MSG_CONTROL)  
  
    if (ch->command == NGX_CMD_OPEN_CHANNEL) {  
  
        if (cmsg.cm.cmsg_len < (socklen_t) CMSG_LEN(sizeof(int))) {  
            ngx_log_error(NGX_LOG_ALERT, log, 0,  
                          "recvmsg() returned too small ancillary data");  
            return NGX_ERROR;  
        }  
  
        if (cmsg.cm.cmsg_level != SOL_SOCKET || cmsg.cm.cmsg_type != SCM_RIGHTS)  
        {  
            ngx_log_error(NGX_LOG_ALERT, log, 0,  
                          "recvmsg() returned invalid ancillary data "  
                          "level %d or type %d",  
                          cmsg.cm.cmsg_level, cmsg.cm.cmsg_type);  
            return NGX_ERROR;  
        }  
  
        /* ch->fd = *(int *) CMSG_DATA(&cmsg.cm); */  
  
        ngx_memcpy(&ch->fd, CMSG_DATA(&cmsg.cm), sizeof(int));  
    }  
  
    if (msg.msg_flags & (MSG_TRUNC|MSG_CTRUNC)) {  
        ngx_log_error(NGX_LOG_ALERT, log, 0,  
                      "recvmsg() truncated data");  
    }  
  
#else  
  
    if (ch->command == NGX_CMD_OPEN_CHANNEL) {  
        if (msg.msg_accrightslen != sizeof(int)) {  
            ngx_log_error(NGX_LOG_ALERT, log, 0,  
                          "recvmsg() returned no ancillary data");  
            return NGX_ERROR;  
        }  
  
        ch->fd = fd;  
    }  
  
#endif  
  
    return n;  
}  

该代码配合发送部分的代码来读,意义很明确。只不过,在我们上面所讲的基础上,Nginx 将 ch 变量作为发送和接收的数据(此数据指放在 iovec 缓冲区中的数据,而非控制信息中的数据部分),并用一个成员 ch->command 实现了一个简单的协议,使得这一对函数功能更通用。