为啥突然想到写这个东西呢,早之前我们在 《面试官:你能实现一个 TCP 之外的可靠传输协议么?》 这篇文章中,在用户空间中基于 UDP 实现了一种简单的可靠传输。前一阵儿和某公司朋友聊天的时候,发现他们公司就在自己弄传输协议玩儿,而且最牛批的是这帮大佬们竟然是直接基于内核搞......
这可着实让我这种业务狗产生了巨大的好奇~
确实如果真的是考虑到 TCP 传输速度慢的话,如果像咱们之前一样在用户空间基于 UDP 做,其实性能还是好不到哪儿去的,因为每次在创建 UDP 链接的时候,都会走底层的 socket 系统调用,每次系统调用又都会从用户态切换到内核态,会导致频繁的寄存器压栈等问题。所以真的想搞一个高性能的传输协议,最好的办法其实是直接在内核中干,也就是说上层进行一次 socket 系统调用,剩下的事情都直接在内核中完成,这样将大大提高代码的运行效率。
可惜对于业务狗来说实在是不会,于是强迫上图中的朋友去好好学习一下,等他学会了,再掰开了揉碎了嚼烂了手把口喂给我哈哈哈哈哈。(有点恶心是吧~)
接下来咱们就尝试一下,直接基于内核态创建一个自定义的 socket 套接字。咱们也不会实现地特别复杂,只要做到如果日后真的碰到这种需要自己在内核中搞个什么自定义传输协议的需求的话,可以稍微有点思路,顺便咱们也可以理清楚 socket 这个东西到底在内核中是如何实现的~
接下来的代码主要通过 C 语言进行实践,毕竟 C 语言比较贴近底层,而且不会用到太难的语法,看源码的时候也不用死乞白赖地扣哧细节,只要能看懂整体框架的思路就够用~
Socket 是个嘛?
首先我们先简单阐述一下 socket 是个啥。
socket 中文翻译叫“套接字”,可能大家都听说过,但是对于单纯的业务狗来讲,可能也就仅限于“听说过”,毕竟大家平时用的网络通信都已经被各种语言或者框架封装地贼简单了。
比如 golang:
import "http"
func main() {
http.HandleFunc(xxxxxx)
http.ListenAndServe(xxxxxx)
}
比如 nodejs:
const http = require("http");
http.createServer(function(request, response) {
// xxxxxxxx
}).listen(xxxxxx);
这样就可以开启简单的服务器然后等待请求过来。但是其实这个就是“外表看似简单,底层却过于复杂的 名侦探柯、 syscall -- socket!”
我们可以以 nodejs 代码为例,来看一下 nodejs 开启一个简单服务器底层所经过的系统调用,来确认一下是不是真的就是对 socket 的封装。
首先我们的代码如下:
const http = require("http");
http.createServer(function(request, response) {
response.write("Ding");// 页面输出
response.end();
}).listen(13190); // 监听端口号
console.log("服务器启动!");
我们可以看到通过 curl 命令可以正常访问这个服务器:
接下来我们通过 strace 这个命令来跟踪查看一下启动这个 node 服务器都进行了哪些系统调用,我们使用如下命令:
strace -f node index.js
(注:mac 系统下可以使用 dtruss 命令进行平替)
如果不出意外的话,我们一定会看到列出的系统调用中,用到了 socket() 以及 bind() 之类的这种东西:
果不其然,在 strace 中列出来的一大堆系统调用中,出现了 socket 和 bind 以及下面的还有个 listen 等系统调用。socket 后面的 “= 20” 这个 20 是文件描述符,我们使用 lsof -i tcp:13190 命令看一下:
能看到这条进程开启的一个文件的 fd 就是 20,没毛病,对上了!
如果使用 strace 去跟踪 golang 的话,也可以看到这些系统调用:
可能有的同学还不太清楚文件描述符是啥,我们一会儿再详细地说一下~
总之通过上面的 demo,我们可以了解到socket 是网络通信的一个基石,确切点说,socket 是“可以用于两条进程之间通信的一种方式,无论两条进程都是本地的也好,是网络中远程的也好”。
在?看看 demo
在大概明白了 socket 是啥以及我们平时的网络通信都离不开 socket 之后,我们用 C 代码为例子,简单看一下如何原汁原味地使用 socket 进行网络通信:
Client 端:
#include <stdio.h>
#include <sys/socket.h>
#include <errno.h>
#include <netinet/in.h>
#include <netinet/udp.h>
#include <string.h>
#include <stdint.h>
#include <unistd.h>
int main() {
// 通过 socket 系统调用创建一个 socket
// AF_INET 表示 ipv4 协议簇
// SOCK_DGRAM 表示套接字类型
// 第三个参数 0 表示协议号
// 具体这仨参数干啥的一会儿再说
int fd = socket(AF_INET, SOCK_DGRAM, 0);
// fd 是套接字接口返回的文件描述符, 干啥的一会儿再说
if (fd < 0) {
printf("socket 执行失败, 没有文件描述符: %s\n", strerror(errno));
return -1;
}
// sockaddr_in 结构用来描述一个套接字地址, 这个在下面的 sendto 使用
struct sockaddr_in addr = {
.sin_family = AF_INET, // 套接字的协议簇
// htons 表示将本机数字转换成网络字节序
.sin_port = htons(3190), // 端口号
// INADDR_ANY 表示"任何本机的地址"
.sin_addr.s_addr = htonl(INADDR_ANY) // 0.0.0.0
};
// 把 sockaddr_in 类型转换成 sockaddr 类型
struct sockaddr *_addr = (struct sockaddr *)&addr;
// 创建一个 buffer 用来存储数据
char buffer[256];
// 创建一个死循环
while (1) {
// 把上面那个 buffer 里头都填充成 0
bzero(buffer, sizeof(buffer));
//从标准输入设备(终端)取得输入的字符串
int len = read(STDIN_FILENO, buffer, sizeof(buffer));
char *prompt = "3 秒后将发送数据\n";
// 往标准输出(终端)中打印上边的字符串
write(STDOUT_FILENO, prompt, strlen(prompt));
// 等待 3s 钟
sleep(3);
// 向上边那个 addr 结构体描述的套接字发送从终端读取的数据
sendto(fd, buffer, sizeof(buffer), 0, _addr, sizeof(addr));
char *msg = "消息已发送\n";
write(STDOUT_FILENO, msg, strlen(msg));
}
// 退出时关闭套接字
close(fd);
return 0;
}
Server 端:
#include <stdio.h>
#include <sys/socket.h>
#include <errno.h>
#include <netinet/in.h>
#include <netinet/udp.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
int main() {
// 通过 socket 系统调用创建一个 socket
// AF_INET 表示 ipv4 协议簇
// SOCK_DGRAM 表示套接字类型
// 第三个参数 0 表示协议号
int fd = socket(AF_INET, SOCK_DGRAM, 0);
if (fd < 0) {
printf("socket() failed,%s\n", strerror(errno));
return -1;
}
// sockaddr_in 结构用来描述一个套接字地址, 这个在下面的 recvfrom 使用
struct sockaddr_in addr = {
.sin_family = AF_INET,
.sin_port = htons(3190),
.sin_addr.s_addr = htonl(INADDR_ANY)
};
// 将这个套接字和 addr 描述进行绑定
bind(fd, (struct sockaddr *)&addr, sizeof(addr));
// 创建一个 buffer
char buffer[256];
// 开启死循环以便一直从套接字接收消息
while (1) {
bzero(buffer, sizeof(buffer));
// 这里可以指定一个远程的 socket 地址, 表示要接收指定的 socket 发来的数据
struct sockaddr_in addr_client;
socklen_t addr_len = sizeof(addr_client);
recvfrom(
fd, buffer, sizeof(buffer), 0,
// 这里最后俩参数也可以写成 NULL, 或者指定一个远程的 socket
(struct sockaddr *)&addr_client, &addr_len
);
if (strlen(buffer) != 0) {
printf("从 client 端接收到: %s\n", buffer);
}
sleep(1);
}
close(fd);
return 0;
}
再简单解释一下上面的代码含义,大概就是:
-
客户端的代码创建了一个套接字,这个套接字要往 0.0.0.0:3190 也就是本地的 3190 端口发送数据,发送的数据是从终端中读取到的用户输入的字符。
-
服务端也创建了一个套接字,这个套接字就是监听 0.0.0.0:3190,0.0.0.0 表示任何的 local 地址,比如访问 localhost:3190 或者 127.0.0.1:3190 或者本机 ip:3190 都可以把流量打到这儿。(注:服务端代码里的addr_client 也可以)写成 NULL。
但是光有代码不行,我们还需要对其进行编译,所以我们写个简单的 Makefile:
all:
gcc test_client.c -o test_client.o -static
gcc test_server.c -o test_server.o -static
然后我们运行:
make
可以看到编译出了两个 .o 的可执行文件。接下来我们跑一下:
通过 ./test_server 加个 & 符号,让服务端代码跑在后台,也就是在后台一直监听着本地的 3190 端口,然后直接 ./test_client.o 启动客户端,客户端代码会监听终端也就是标准输入来的数据,我们随便输入点啥,比如 test1 或者 test2 之类的,可以看到每次输入完成回车后会等待 3s,然后服务端的代码就打印出了刚刚输入的数据。
当然实验完了别忘了把它 kill 掉,不然它会一直占用着端口:
简单的实验玩完了之后,我们来简单解释一下 socket 这个系统调用的参数含义。
首先来看:
int fd = socket(AF_INET, SOCK_DGRAM, 0);
- 第一个参数叫做 “套接字协议簇”。所谓“协议簇”,其实就是“一组协议的集合”,比如这里的 “AF_INET” 就表示 “ipv4 协议簇”,说直白一点就是 “对于这个 socket,使用 ipv4 协议以及 ipv4 协议对应的它能使用的上层协议”。
对于 “它能使用的上层协议” 这句话,可能看着还是有点别扭,我们可以来直接看一下 Linux 的内核源码中对应的实现,以方便我们对于理解 “协议簇” 的概念:
Linux 的源码目录中有很多的目录,我们不一一做介绍,这里我们暂时只看 net 目录下,可以看到这个 net 目录有好多东西,这些都是 Linux 下可以使用的协议,其中有个 ipv4 目录(也一定会有 ipv6 目录),这个 ipv4 目录下有个 af_inet.c 文件,在这个文件中,我们可以看到 Linux 内核写死了一个类型是 inet_protosw名字是 inetsw_array的数组,我们看每一项的 protocol 属性,可以看到:IPPROTO_TCP、IPPROTO_UDP、IPPROTO_ICMP、IPPROTO_IP 这四个协议,没错,这个数组就是所谓的 “ipv4 协议簇”,我们都知道,ICMP、UDP 以及 TCP 都是 IP 协议的上层(四层)协议,也就是我们上面所说的 “ipv4 协议以及 ipv4 协议对应的它能使用的上层协议”。
对于 ipv4 是这样直接在内核中定义的,其实对于其他协议簇来讲也差不多是这样,定义协议簇的方法可能各式各样,但是大都是直接提前内置在内核中的。我们可以再简单看下 ipv6 协议簇如何定义。我们在 net/ipv6 下可以看到一个和 ipv4 的 af_inet.c 很像的文件叫做 af_inet6.c:
它里头倒是没有直接写个“全局大数组”,而是定义了一个inet6_register_protosw的函数,全局搜索的话可以看到,Linux 在不同的时机调用这个方法去给 ipv6 协议簇注册了一些不同的可使用的协议,这些协议加载一起就是 ipv6 的协议簇:
所以协议簇指的就是 “一组协议的集合”,而这组 “集合” 的定义方式可能都不一样。
- 接下来我们看下第二个参数:
int fd = socket(AF_INET, SOCK_DGRAM, 0);
第二个参数是 “SOCK_DGRAM”,它表示这个套接字的类型。
这个所谓的 “类型” 比较常用的有 SOCK_STREAM和SOCK_DGRAM,其中前者表示 “面向连接”,后者表示 “面向非连接”。当然还有一些其他类型,不过这两个相对来说是最常用的。
所以其实 socket 的这第二个参数的意思就是说 “当前这个套接字是一个面向连接/非连接的类型”。我们在上面也看到了,比如向 ipv4 这个协议簇,刚刚我们看到了它这个 “集合中” 除了 ipv4 协议本身之外,还有 tcp、udp、icmp 可使用的三个上层协议,我们都知道只有 tcp 是 “面向连接” 的,所以如果 socket 的第二个参数传了 SOCK_STREAM的话,在 socket 不传递第三个参数的情况下,内核底层会默认选择使用 tcp 作为这个套接字的四层协议。
- 第三个参数:
int fd = socket(AF_INET, SOCK_DGRAM, 0);
这里我们把第三个参数顺带着说一下,刚刚在上面看第二个参数的时候我们说过这第二个参数表示“面向连接”,“面向非连接”等类型,对于“面向连接”的类型的话,我们只有 tcp 这一个选择,但是相反的,如果我们选择“面向非连接”,那就至少有 udp 和 icmp 这两种协议可以选择,所以这第三个参数就是在“给 socket 规定了某种类型之后,如果这种类型有多种上层协议可以使用的话,用户可以自主选择一个对应的上层协议”,如果用户自己不传第三个参数的话,那内核会自动根据第二个参数的“类型”,帮用户选择一个默认的上层协议。
所以总结地来说一下 socket 的三个参数的含义就是:为 socket 选择一种 “协议簇”,这个协议簇包含了这个套接字可以使用的三层协议和这个三层协议可以使用的四层协议的集合,然后用户一定要规定一下这个协议簇的“类型”,同时由于有的类型的可能对应多种上层协议,所以用户可以自己“指定要使用的协议”,如果自己不指定,内核将根据“类型”选择一个默认的。
到这里我们了解了 socket 这个系统调用的参数含义,对于 demo 中用到的其他一些系统调用我们就简单介绍一下:
sendto:表示这个套接字要往哪个远程(或本地)的另一个套接字发送数据
bind:让这个套接字绑定一个 ip 和 port
recvfrom:接收从远程(或本地)的套接字发来的数据
对于套接字相关的系统调用来说,其实还有很多其他的 api,比如什么 recv、send、accept 等,我们不一一介绍,大家感兴趣可以自己再去多加了解~
对于 socket 周边的一些 api 我们不做详细介绍,但是有个东西我们一定要介绍一下,那就是刚刚上面 demo 中频繁出现的一个变量 “fd”。
这个 “fd” 变量我们在注释当中一直管它叫 “文件描述符”,这个玩意儿非常重要,可以说你创建了一个套接字之后,任何对于这个套接字的操作,都要通过这个小小的文件描述符来完成。
那么什么是文件描述符呢~我们放到下一篇文章再继续讲。