一、什么是 Socket
在网络上的两个程序通过一个双向的通信连接实现数据的交换,这个连接的一端称为一个Socket。Socket 可以被定义描述为两个应用通信通道的端点,一个 Socket 端点可以用 Socket 地址(地址 IP、端口、协议组成)来描述。Socket 作为一种进程通信机制,操作系统会分配唯一一个 Socket 标识,这个标识与通讯协议有关(不仅限于 TCP 或 UDP)。
二、什么是 Unix Domain Socket
Unix Domain Socket 并不是一个实际的协议,它只在同客户机和服务器通信时使用的 API,且一台主机与在不同主机间通信时使用相同的 API。
有以下特点
- Unix Domain Socket 使用的地址通常是一个文件
- 在同一主机通讯时,传输速率是不同主机间的两倍
- Unix Domain Socket 套接字描述符可以在同一主机不同进程间传递
- Unix Domain Socket 套接字可以向服务器提供用户认证信息
三、TCP Socket 与 Unix Domain Socket
无论时 TCP Socket 套接字还是 Unix Domain Socket 套接字,每个套接字都是唯一的。TCP Socket 通过 IP 和端口描述,而 Unix Domain Socket 描述。
TCP 属于传输层的协议,使用 TCP Socket 进行通讯时,需要经过传输层 TCP/IP 协议的解析。
而 Unix Domain Socket 可用于不同进程间的通讯和传递,使用 Unix Domain Socket 进行通讯时不需要经过传输层,也不需要使用 TCP/IP 协议。所以,理论上讲 Unix Domain Socket 具有更好的传输效率。
四、什么场景需要使用 UDS
4.1 本机通信
当我们需要在本机通信时,可以使用 uds 来代替本地回环接口。uds 相比 TCP/UDP 套接字性能会更好,因为它不需要经过网络协议栈,省去了各种解析和应答等步骤,而是直接在内核拷贝传递数据。比如最近很热的 service mesh,业务进程和 sidecar 就可以通过 uds 来通信。
4.2 传递描述符
当我们需要传递描述符时,通常可以使用方法有:
- fork 调用返回以后,子进程共享父进程的所有描述符
- exec 调用执行后,所有的描述符通常保持打开状态
第一种方式里,我们可以把描述符从父进程传递到子进程,然而我们也可能需要在子进程传递描述符到父进程。unix 系统提供了用于从一个进程向其他任意进程传递描述符的方式,而这两个进程不需要有任何亲缘关系。这种技术要求在两个进程之间创建一个 uds,然后使用 sendmsg 通过这个 uds 发送特殊结构的消息。这个特殊的消息会由内核处理,把打开的描述符从发送进程传递到接收进程
通过 uds 传递描述符的步骤具体如下:
- 创建一个字节流或数据报的 uds。这可以通过调用 socketpair 然后父子进程之间的连接;也可以使用套接字 API。通常建议使用字节流套接字而不是数据报套接字,因为使用数据报套接字并没有什么好处,反而还存在数据报被丢弃的可能。
- 发送端打开描述符。uds 可以传递各种类型的描述符,而不是仅包括文件描述符。
- 发送端进程创建一个 msghdr 的结构,其中含有待传递的描述符,然后调用 sendmsg 将其发送出去。发送一个描述符会使其引用计数加一。
- 接收端进程调用 recvmsg 在创建的 uds 上接收描述符。这个过程会在接收进程创建一个新的描述符,然后将其指向和发送进程发送的描述符指向的同一个内核文件选项。所以接收端收到的描述符不同于发送端发送端描述符时很正常的。
msghdr 的结构定义:
/*
* [XSI] Message header for recvmsg and sendmsg calls.
* Used value-result for recvmsg, value only for sendmsg.
*/
struct msghdr {
void *msg_name; /* [XSI] optional address */
socklen_t msg_namelen; /* [XSI] size of address */
struct iovec *msg_iov; /* [XSI] scatter/gather array */
int msg_iovlen; /* [XSI] # elements in msg_iov */
void *msg_control; /* [XSI] ancillary data, see below */
socklen_t msg_controllen; /* [XSI] ancillary data buffer len */
int msg_flags; /* [XSI] flags on received message */
};
4.3 验证发送者的身份
可以用 uds 传递的另一种辅助数据就是用户凭证。用户凭证的数据结构在不同的操作系统中并不一致,这里就不再详细介绍了。
五、Unix 域原理
Node.js 中,Unix 域采用的是一个文件作为标记。大致原理如下。
- 服务器首先拿到一个 socket。
- 服务器 bind 一个文件,类似 bind 一个 IP 和端口一样,对于操作系统来说,就是新建一个文件(不一定是在硬盘中创建,可以设置抽象路径名),然后把文件路径信息存在 socket 中。
- 调用 listen 修改 socket 状态为监听状态。
- 客户端通过同样的文件路径调用 connect 去连接服务器。这时候用于表示客户端的结构体插入服务器的连接队列,等待处理。
- 服务器调用 accept 摘取队列的节点,然后新建一个通信 socket 和客户端进行通信。
本质:
Unix 域通信本质还是基于内存之间的通信,客户端和服务器都维护一块内存,这块内存分为读缓冲区和写缓冲区。从而实现全双工通信,而 Unix 域的文件路径,只不过是为了让客户端进程可以找到服务端进程,后续就可以互相往对方维护的内存里写数据,从而实现进程间通信。
六、实战
6.1 Nginx 对 UNIX Socket 的支持
当使用 TCP 端口时,可以像下面这样配置:
server {
listen 80;
server_name itbilu.com;
location /
{
proxy_pass http://127.0.0.1:3000; //要代理的实际端口(Node.js服务器端口),请求将被定向到此端口
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_set_header X-NginX-Proxy true;
proxy_set_header Connection "";
proxy_http_version 1.1;
}
access_log /var/log/nginx/access/itbilu.log;
}
server {
server_name www.itbilu.com;
rewrite ^(.*) http://itbilu.com$1 permanent;
}
使用 UNIX Socket 套接字文件时,可以像下面这样配置:
server {
listen 80;
server_name itbilu.com;
location /
{
proxy_pass /var/3w/itbilu.sock; //要代理的实际Unix套接字文件(Node.js服务器监听的文件),请求将被定向到此路径
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_set_header X-NginX-Proxy true;
proxy_set_header Connection "";
proxy_http_version 1.1;
}
access_log /var/log/nginx/access/itbilu.log;
}
server {
server_name www.itbilu.com;
rewrite ^(.*) http://itbilu.com$1 permanent;
}
6.2 在 node.js 中监听 unix domain socket
启动服务器,有以下几种形式:
- server.listen(port[, hostname][, backlog][, callback]):从一个 TCP 端口启动监听
- server.listen(path, [callback]):从一个 UNIX Socket 套接字启动监听
- server.listen(handle[, callback]):从一个包含 socket 套接字或文件描述符的 handle 对象启动监听
// 从'127.0.0.1'和3000端口开始接收连接
server.listen(3000, '127.0.0.1', () => {
console.log('在端口3000启动了服务器');
});
// 从 UNIX 套接字所在路径 path 上监听连接
server.listen('path/to/socket', () => {
console.log('从socket路径启动了服务器');
})
在上面示例中,使用 port 端口形式的监听,本质是监听TCP Socket 端口。而使用 path 文件路径的方式,本质上是 Unix Domain Socket(Unix 域套接字)文件。