进程间传递描述符一
每个进程都拥有自己独立的进程空间,这使得描述符在进程之间的传递变得有点复杂,这个属于高级进程间通信的内容,下面就来说说。顺便把 Linux 和 Windows 平台都讲讲。
Linux 下的描述符传递
Linux 系统系下,子进程会自动继承父进程已打开的描述符,实际应用中,可能父进程需要向子进程传递“后打开的描述符”,或者子进程需要向父进程传递;或者两个进程可能是无关的,显然这需要一套传递机制。
简单的说,首先需要在这两个进程之间建立一个 Unix 域套接字接口作为消息传递的通道( Linux 系统上使用socketpair 函数可以很方面便的建立起传递通道),然后发送进程调用 sendmsg 向通道发送一个特殊的消息,内核将对这个消息做特殊处理,从而将打开的描述符传递到接收进程。
然后接收方调用 recvmsg 从通道接收消息,从而得到打开的描述符。然而实际操作起来并不像看起来那样单纯。
先来看几个注意点:
1 需要注意的是传递描述符并不是传递一个 int 型的描述符编号,而是在接收进程中创建一个新的描述符,并且在内核的文件表中,它与发送进程发送的描述符指向相同的项。
2 在进程之间可以传递任意类型的描述符,比如可以是 pipe , open , mkfifo 或 socket , accept 等函数返回的描述符,而不限于套接字。
3 一个描述符在传递过程中(从调用 sendmsg 发送到调用 recvmsg 接收),内核会将其标记为“在飞行中”( in flight )。在这段时间内,即使发送方试图关闭该描述符,内核仍会为接收进程保持打开状态。发送描述符会使其引用计数加 1 。
4 描述符是通过辅助数据发送的(结构体 msghdr 的 msg_control 成员),在发送和接收描述符时,总是发送至少1 个字节的数据,即使这个数据没有任何实际意义。否则当接收返回 0 时,接收方将不能区分这意味着“没有数据”(但辅助数据可能有套接字)还是“文件结束符”。
5 具体实现时, msghdr 的 msg_control 缓冲区必须与 cmghdr 结构对齐,可以看到后面代码的实现使用了一个union 结构来保证这一点。
msghdr 和 cmsghdr 结构体
上面说过,描述符是通过结构体 msghdr 的 msg_control 成员送的,因此在继续向下进行之前,有必要了解一下msghdr 和 cmsghdr 结构体,先来看看 msghdr 。
- struct msghdr {
- void *msg_name;
- socklen_t msg_namelen;
- struct iovec *msg_iov;
- size_t msg_iovlen;
- void *msg_control;
- size_t msg_controllen;
- int msg_flags;
- };
结构成员可以分为下面的四组,这样看起来就清晰多了:
1 套接口地址成员 msg_name 与 msg_namelen ;
只有当通道是数据报套接口时才需要; msg_name 指向要发送或是接收信息的套接口地址。 msg_namelen 指明了这个套接口地址的长度。
msg_name 在调用 recvmsg 时指向接收地址,在调用 sendmsg 时指向目的地址。注意, msg_name 定义为一个(void *) 数据类型,因此并不需要将套接口地址显示转换为 (struct sockaddr *) 。
2 I/O 向量引用 msg_iov 与 msg_iovlen
它是实际的数据缓冲区,从下面的代码能看到,我们的 1 个字节就交给了它;这个 msg_iovlen 是 msg_iov 的个数,不是什么长度。
msg_iov 成员指向一个 struct iovec 数组, iovc 结构体在 sys/uio.h 头文件定义,它没有什么特别的。
- struct iovec {
- ptr_t iov_base; /* Starting address */
- size_t iov_len; /* Length in bytes */
- };
有了 iovec ,就可以使用 readv 和 writev 函数在一次函数调用中读取或是写入多个缓冲区,显然比多次 read ,write 更有效率。 readv 和 writev 的函数原型如下:
- #include <sys/uio.h>
- int readv(int fd, const struct iovec *vector, int count);
- int writev(int fd, const struct iovec *vector, int count);
3 附属数据缓冲区成员 msg_control 与 msg_controllen ,描述符就是通过它发送的,后面将会看到, msg_control指向附属数据缓冲区,而 msg_controllen 指明了缓冲区大小。
4 接收信息标记位 msg_flags ;忽略
轮到 cmsghdr 结构了,附属信息可以包括若干个单独的附属数据对象。在每一个对象之前都有一个 struct cmsghdr结构。头部之后是填充字节,然后是对象本身。最后,附属数据对象之后,下一个 cmsghdr 之前也许要有更多的填充字节。
- struct cmsghdr {
- socklen_t cmsg_len;
- int cmsg_level;
- int cmsg_type;
- /* u_char cmsg_data[]; */
- };
cmsg_len 附属数据的字节数,这包含结构头的尺寸,这个值是由 CMSG_LEN() 宏计算的;
cmsg_level 表明了原始的协议级别 ( 例如, SOL_SOCKET) ;
cmsg_type 表明了控制信息类型 ( 例如, SCM_RIGHTS ,附属数据对象是文件描述符; SCM_CREDENTIALS,附属数据对象是一个包含证书信息的结构 ) ;
被注释的 cmsg_data 用来指明实际的附属数据的位置,帮助理解。
对于 cmsg_level 和 cmsg_type ,当下我们只关心 SOL_SOCKET 和 SCM_RIGHTS 。
msghdr 和 cmsghdr 辅助宏
这些结构还是挺复杂的, Linux 系统提供了一系列的宏来简化我们的工作,这些宏可以在不同的 UNIX 平台之间进行移植。这些宏是由 cmsg(3) 的 man 手册页描述的,先来认识一下:
#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);
void *CMSG_DATA(struct cmsghdr *cmsg);
CMSG_LEN() 宏
输入参数:附属数据缓冲区中的对象大小;
计算 cmsghdr 头结构加上附属数据大小,包括必要的对其字段,这个值用来设置 cmsghdr 对象的 cmsg_len 成员。
CMSG_SPACE() 宏
输入参数:附属数据缓冲区中的对象大小;
计算 cmsghdr 头结构加上附属数据大小,并包括对其字段和可能的结尾填充字符,注意 CMSG_LEN() 值并不包括可能的结尾填充字符。 CMSG_SPACE() 宏对于确定所需的缓冲区尺寸是十分有用的。
注意如果在缓冲区中有多个附属数据,一定要同时添加多个 CMSG_SPACE() 宏调用来得到所需的总空间。
下面的例子反映了二者的区别:
- printf("CMSG_SPACE(sizeof(short))=%d/n", CMSG_SPACE(sizeof(short))); // 返回16
- printf("CMSG_LEN(sizeof(short))=%d/n", CMSG_LEN(sizeof(short))); // 返回14
CMSG_DATA() 宏
输入参数:指向 cmsghdr 结构的指针 ;
返回跟随在头部以及填充字节之后的附属数据的第一个字节 ( 如果存在 ) 的地址,比如传递描述符时,代码将是如下的形式:
- struct cmsgptr *cmptr;
- . . .
- int fd = *(int *)CMSG_DATA(cmptr); // 发送:*(int *)CMSG_DATA(cmptr) = fd;
CMSG_FIRSTHDR() 宏
输入参数:指向 struct msghdr 结构的指针;
返回指向附属数据缓冲区内的第一个附属对象的 struct cmsghdr 指针。如果不存在附属数据对象则返回的指针值为NULL 。
CMSG_NXTHDR() 宏
输入参数:指向 struct msghdr 结构的指针,指向当前 struct cmsghdr 的指针;
这个用于返回下一个附属数据对象的 struct cmsghdr 指针,如果没有下一个附属数据对象,这个宏就会返回 NULL。
通过这两个宏可以很容易遍历所有的附属数据,像下面的形式:
- struct msghdr msgh;
- struct cmsghdr *cmsg;
- for (cmsg = CMSG_FIRSTHDR(&msgh); cmsg != NULL;
- cmsg = CMSG_NXTHDR(&msgh,cmsg) {
- // 得到了cmmsg,就能通过CMSG_DATA()宏取得辅助数据了
函数 sendmsg 和 recvmsg
函数原型如下:
- #include <sys/types.h>
- #include <sys/socket.h>
- int sendmsg(int s, const struct msghdr *msg, unsigned int flags);
- int recvmsg(int s, struct msghdr *msg, unsigned int flags);
二者的参数说明如下:
s, 套接字通道,对于 sendmsg 是发送套接字,对于 recvmsg 则对应于接收套接字;
msg ,信息头结构指针;
flags , 可选的标记位, 这与 send 或是 sendto 函数调用的标记相同。
函数的返回值为实际发送 / 接收的字节数。否则返回 -1 表明发生了错误。
具体参考 APUE 的高级 I/O 部分,介绍的很详细。
好了准备工作已经做完了,下面就准备进入正题。
进程间传递描述符二
发送、接收描述符
发送描述符
经过了前面的准备工作,是时候发送描述符了,先来看看函数原型:
int write_fd(int fd, void *ptr, int nbytes, int sendfd);
参数说明如下:
@fd :发送 TCP 套接字接口;这个可以是使用socketpair返回的发送套接字接口
@ptr :发送数据的缓冲区指针;
@nbytes :发送的字节数;
@sendfd :向接收进程发送的描述符;
函数返回值为写入的字节数, <0 说明发送失败;
废话少说,代码先上,发送描述符的代码相对简单一些,说明见代码内注释。
先说明一下,旧的 Unix 系统使用的是 msg_accrights 域来传递描述符,因此我们需要使用宏HAVE_MSGHDR_MSG_CONTROL 以期能同时支持这两种版本。
- int write_fd(int fd, void *ptr, int nbytes, int sendfd)
- {
- struct msghdr msg;
- struct iovec iov[1];
- // 有些系统使用的是旧的msg_accrights域来传递描述符,Linux下是新的msg_control字段
- #ifdef HAVE_MSGHDR_MSG_CONTROL
- union{ // 前面说过,保证cmsghdr和msg_control的对齐
- struct cmsghdr cm;
- char control[CMSG_SPACE(sizeof(int))];
- }control_un;
- struct cmsghdr *cmptr;
- // 设置辅助缓冲区和长度
- msg.msg_control = control_un.control;
- msg.msg_controllen = sizeof(control_un.control);
- // 只需要一组附属数据就够了,直接通过CMSG_FIRSTHDR取得
- cmptr = CMSG_FIRSTHDR(&msg);
- // 设置必要的字段,数据和长度
- cmptr->cmsg_len = CMSG_LEN(sizeof(int)); // fd类型是int,设置长度
- cmptr->cmsg_level = SOL_SOCKET;
- cmptr->cmsg_type = SCM_RIGHTS; // 指明发送的是描述符
- *((int*)CMSG_DATA(cmptr)) = sendfd; // 把fd写入辅助数据中
- #else
- msg.msg_accrights = (caddr_t)&sendfd; // 这个旧的更方便啊
- msg.msg_accrightslen = sizeof(int);
- #endif
- // UDP才需要,无视
- msg.msg_name = NULL;
- msg.msg_namelen = 0;
- // 别忘了设置数据缓冲区,实际上1个字节就够了
- iov[0].iov_base = ptr;
- iov[0].iov_len = nbytes;
- msg.msg_iov = iov;
- msg.msg_iovlen = 1;
- return sendmsg(fd, &msg, 0);
- }
接收描述符
发送方准备好之后,接收方准备接收,函数原型为:
int read_fd(int fd, void *ptr, int nbytes, int *recvfd);
参数说明如下:
@fd :接收 TCP 套接字接口; 这个可以是使用 socketpair返回的接收套接字接口
@ptr :接收数据的缓冲区指针;
@nbytes :接收缓冲区大小;
@recvfd :用来接收发送进程发送来的描述符;
函数返回值为读取的字节数, <0 说明读取失败;
接收函数代码如下,相比发送要复杂一些。
- int read_fd(int fd, void *ptr, int nbytes, int *recvfd)
- {
- struct msghdr msg;
- struct iovec iov[1];
- int n;
- int newfd;
- #ifdef HAVE_MSGHDR_MSG_CONTROL
- union{ // 对齐
- struct cmsghdr cm;
- char control[CMSG_SPACE(sizeof(int))];
- }control_un;
- struct cmsghdr *cmptr;
- // 设置辅助数据缓冲区和长度
- msg.msg_control = control_un.control;
- msg.msg_controllen = sizeof(control_un.control);
- #else
- msg.msg_accrights = (caddr_t) &newfd; // 这个简单
- msg.msg_accrightslen = sizeof(int);
- #endif
- // TCP无视
- msg.msg_name = NULL;
- msg.msg_namelen = 0;
- // 设置数据缓冲区
- iov[0].iov_base = ptr;
- iov[0].iov_len = nbytes;
- msg.msg_iov = iov;
- msg.msg_iovlen = 1;
- // 设置结束,准备接收
- if((n = recvmsg(fd, &msg, 0)) <= 0)
- {
- return n;
- }
- #ifdef HAVE_MSGHDR_MSG_CONTROL
- // 检查是否收到了辅助数据,以及长度,回忆上一节的CMSG宏
- cmptr = CMSG_FIRSTHDR(&msg);
- if((cmptr != NULL) && (cmptr->cmsg_len == CMSG_LEN(sizeof(int))))
- {
- // 还是必要的检查
- if(cmptr->cmsg_level != SOL_SOCKET)
- {
- printf("control level != SOL_SOCKET/n");
- exit(-1);
- }
- if(cmptr->cmsg_type != SCM_RIGHTS)
- {
- printf("control type != SCM_RIGHTS/n");
- exit(-1);
- }
- // 好了,描述符在这
- *recvfd = *((int*)CMSG_DATA(cmptr));
- }
- else
- {
- if(cmptr == NULL) printf("null cmptr, fd not passed./n");
- else printf("message len[%d] if incorrect./n", cmptr->cmsg_len);
- *recvfd = -1; // descriptor was not passed
- }
- #else
- if(msg.msg_accrightslen == sizeof(int)) *recvfd = newfd;
- else *recvfd = -1;
- #endif
- return n;
- }
发送和接收函数就这么多,就像上面看到的,进程间传递套接字还是有点麻烦的。Linux的就介绍完了,后面在简单说说Windows是如何传递的,话说MSDN真的方便哪。
进程间传递描述符三
--Windows上发送接收套接字(Socket)
Windows平台上内核对象都是HANDLE,如果要在进程间传递内核对象,Windows提供了DuplicateHandle函数。复制的HANDLE和原HANDLE实际上指向的是内核中的同一个对象。
对于Socket而言,则需要使用WSADuplicateSocket来传递Socket,这个操作不像DuplicateHandle那么直观,先来看看函数原型:
int WSADuplicateSocket(
SOCKET s, // 要复制的socket对象
DWORD dwProcessId, // 接收进程的进程号
LPWSAPROTOCOL_INFO lpProtocolInfo // 把Socket的信息复制到这里
);
了解这个函数后,就可以简单的步骤。
1 首先发送进程要知道接收进程是谁,这个得靠进程间通信了,比如一块共享内存。
2 确认接收进程后,接着发送进程调用WSADuplicateSocket取得一个WSAPROTOCOL_INFO结构,这个结构是复制Socket的关键,不过不必太关心这个结构里面都是什么东西。
3 发送进程拿到这个结构后,得把它交给接收进程,拿到WSAPROTOCOL_INFO后,接收进程就可以复制发送进程的Socket了,这个也可以通过共享内存传递。
4 接收进程调用WSASocket函数,传递这个接收到的WSAPROTOCOL_INFO结构就可以复制Socket了。
需要注意的是,复制的Socket并不是一个真正的新的socket,只是一个复制品,它和原Socket共享所有的状态信息。
如果原Socket是使用socket函数创建的,那么接收进程调用WSASocket时必须指明WSA_FLAG_OVERLAPPED标记。
MSDN上介绍的很详细,见下面的时序表格:
发送进程 | IPC | 接收进程 |
1)由 WSASocket , WSAConnect 获取Socket |
|
|
2) 请求接收进程ID号 (可以通过共享内存取得) | ==> |
|
|
| 3) 传递给发送进程ID号 |
4) 获取接收进程ID号 | <== |
|
5) 调用函数WSADuplicateSocket 得到WSAPROTOCOL_INFO 结构体对象 |
|
|
6) 将WSAPROTOCOL_INFO 对象发送给目标进程 |
|
|
| ==> | 7) 取得WSAPROTOCOL_INFO结构 |
|
| 8) 调用WSASocket创建共享Socket描述符. |
|
| 9) 使用Socket进行数据通信 |
10) 调用closesocket 关闭Socket | <== |
|
从上面的时序表格,很容易就能写出Windows上进程间传递Socket的函数了。