【计算机网络】1、概念、TCP | UDP | 本地 socket 编程
文章目录
- 一、网络基本概念
-
- 1.1 端口(port)
- 1.2 IP 地址 = 网络地址(network)和 主机(host)
- 1.3 子网(subnet)
- 1.4 子网掩码(netmask)
- 1.5 保留网段
- 1.6 CIDR 表述形式:192.168.0.1/27 的含义
- 二、socket
-
- 2.1 sockaddr 格式
-
- 2.1.1 IPv4 sockaddr 格式
- 2.1.2 IPv6 sockaddr 格式
- 2.1.3 本地 sockaddr 格式
- 2.2 http 与 websocket
- 三、TCP 编程
-
- 3.1 server 端
-
- 3.1.1 socket() 创建套接字
- 3.1.2 bind() 设定电话号码
- 3.1.3 listen() 接上电话线,一切准备就绪
- 3.1.4 accept() 电话铃响起了
- 3.2 client 端
-
- 3.2.1 connect() 拨打电话
- 3.3 三次握手
- 3.4 read/write 数据
-
- 3.4.1 write
-
- 3.4.1.1 发送缓冲区
- 3.4.2 read
- 3.4.3 缓冲区实验
- 四、UDP 编程
-
- 4.1 server 端
- 4.2 client 端
- 4.3 实验
- 4.4 udp 的 connect()
-
- 4.4.1 client 的 connect()
- 4.4.2 server 的 connect()
- 五、本地 socket 编程
-
- 5.1 本地字节流 socket
-
- 5.1.1 只启动 client
- 5.1.2 server 监听在无权限的文件路径上
- 5.1.3 server - client 应答
- 5.2 本地数据报 socket
- 5.3 k8s 和 docker 的 socket 案例
一、网络基本概念
项目源码地址
1.1 端口(port)
一台计算机的 「IP地址」是固定的,但可以存在多个连接(比如手机作为 client 可以同时用微信、飞书;服务器作为 server 可以同时有 ssh、http 服务)。怎么区分这些呢?就引入了「port」的概念:每个连接的 port 不同。
port 是 16 位整数,最多 65535,其中小于 5000 的一般由系统占用。当 client 其请求时,client 的 port 由 os 临时分配;而 server 的 port 一般是一个众做周知的。
用(clientip:clientport,serverip,serverport) 四元组,可唯一标识一个连接。
1.2 IP 地址 = 网络地址(network)和 主机(host)
IP 地址分为 网络地址(network)和主机(host)两部分:
- 网络地址(network):指这组 IP 共同的部分,比如在 192.168.1.1~192.168.1.255 这个区间里,共同的部分是 192.168.1.0。
- 主机(host):指这组 IP 不同的部分,比如上例中 1~255 表示有 255 个不同的 IP。
- 例如 IPv4 地址 192.0.2.12。若视其前三个 byte 为 network,最后一个 byte 为 host。则其子网掩码(netmask)可表示为 192.0.2.0/24(255.255.255.0)。
其中网络地址(network):是由 IP 和 子网掩码(netmask)按位与 计算而来的。
例1:若 IP 地址是 192.168.2.99(二进制是11000000.10101000.00000010.01100011)。
- 有一个 255.255.255.252(二进制为 11111111.11111111.11111111.11111100) 的子网掩码(netmask)。其意为 30位的网络地址(network)和 2位的主机(host),即意为最多 4 台 host(因为 netmask 只有最后两位不变,即 22=42^2=422=4 台 host)。
- 则 网络地址(network)是 255.255.255.252 & 192.168.2.163 的结果,即为二进制 11000000.10101000.00000010.01100000(即表示为十进制的 192.168.2.96 )
例2:若 IP 是 192.168.0.12,子网掩码是 255.255.255.252,则网络地址为 192.0.2.0,计算过程如下表。
概念 | byte1 | byte2 | byte3 | byte4 | 可视化的值 |
---|---|---|---|---|---|
IP 地址 | 11000000 | 00000000 | 00000010 | 00000000 | 192.0.2.12 |
子网掩码 | 11111111 | 11111111 | 11111111 | 00000000 | 255.255.255.0 |
网络地址 | 11000000 | 00000000 | 00000010 | 00000000 | 192.0.2.0 |
1.3 子网(subnet)
子网(subnet):是指一个 IPv4 地址的第一个、前两个、前三个 byte 是网络(network)的一部分。
- 「A 类网络(Class A)」:是指有一个 byte 的网络(network),和三个 byte 的主机(host),那么你会有 224=167772162^{24} = 16777216224=16777216 个地址。我们将一个字节的子网(subnet)记作 255.0.0.0。
- 「B 类网络(Class B)」:是指有两个 byte 的网络(network),和两个 byte 的主机(host),那么你会有 216=655362^{16} = 65536216=65536 个地址。我们将两个字节的子网(subnet)记作 255.255.0.0。
- 「C 类网络(Class C)」:是指有三个 byte 的网络(network),和一个 byte 的主机(host),那么你会有 28=2562^{8} = 25628=256 个地址。我们将三个字节的子网(subnet)记作 255.255.255.0。
1.4 子网掩码(netmask)
- 永远是前半部分二进制全为 1,而后半部分二进制全为 0
- 能接受任意个位,而不只局限于上文讨论的 8、16、24 个比特
- 不过一大串的数字会有点不好用,比如像 255.192.0.0 这样的子网掩码,人们无法直观地知道有多少个 1,多少个 0,后来人们发明了新的办法,你只需要将一个斜线放在 IP 地址后面,接着用一个十进制的数字用以表示网络的位数,类似这样:192.0.2.12/30,这样就很容易知道有 30 个 1, 2 个 0,所以主机个数为 4。
1.5 保留网段
一个比较常见的现象是,我们所在的单位或者组织,普遍会使用诸如 10.0.x.x 或者 192.168.x.x 这样的 IP 地址,你可能会纳闷,这样的 IP 到底代表了什么呢?不同的组织使用同样的 IP 会不会导致冲突呢?
背后的原因是这样的,国际标准组织在 IPv4 地址空间里面,专门划出了一些网段,这些网段不会用做公网上的 IP,而是仅仅保留做内部使用,我们把这些地址称作保留网段。
下表是三个保留网段,分别可容纳 16777216 、1048576 和 65536 个主机。
- 下图第二行:Largest CIDR block(subnet mask) 是 172.16.0.0/12 ,Classful description 描述为 16 个连续的 B 段地址:
- 是因为从 172.16.0.0/12 中得出信息,172.16.0.0 为 B 类网,12 为网络号,默认 B 类网的网络号是 2*8=16 位,而此处为 12 位,那么便有 2^(16-12) = 16 个连续的子网
- 下图第三行 Largest CIDR block(subnet mask) 是 192.168.0.0/16,Classful description 描述为 256 个连续的 C 段地址:
- 是因为从 192.168.0.0/16 得出信息,192.168.0.0 为 C 类网,16 为网络号,默认 C 类网的网络号是 3*8=24 位,而此处为 16 位,那么便有 2^(24-16) = 256 个连续的子网
1.6 CIDR 表述形式:192.168.0.1/27 的含义
首先得明白 192.168.0.1 是个 IP 地址,更明确的话是属于 C 类型的(因为 C 类型是 24 位,再借用 3 位,则刚好 27 位),后面的 /27 则表示 网络号 的长度,也叫 VLSM(Variable Length Subnet Mask,可变长子网掩码),192.168.0.1/27 属于 CIDR (无类别域间路由,Classless Inter-Domain Routing) 表述形式。
IP 地址是以 点 分割为 四部分,每部分 8 bit (位) 也就是一个 byte (字节)。在 C 类地址中,网络号占 24 bit,主机号占 8 bit
网络号(network) | 主机号(host) |
---|---|
11111111 11111111 11111111 | 00000000 |
上面的 /27 说明网络号占了 27 bit
网络号(network) | 主机号(host) |
---|---|
11111111 11111111 11111111 | 111 00000 |
- 网络号 network:即 网络号 向 主机号 借了 3 bit,说明有 23=82^3=823=8个子网,每个子网可用主机数为 25−2=302^5-2=3025−2=30,这里 -2 是因为头尾的 网络地址 和 广播地址 是不可用的。
- 子网掩码 netmask:可表示为 255.255.255.224,也可表示为 255.255.255.224/27 也就是上面二进制转换为的十进制表示。
- 网络地址:是 IP 地址和 子网掩码 按位与,结果为 192.168.0.0,计算过程可参见下表
- 广播地址:则是在 网络地址 的基础上把 主机号 从 「右往左数 5 个」 「二进制 1 的位」 而得到 192.168.0.31。故有效的 IP 地址为 192.168.0.1 到 192.168.0.30。当向广播地址发送请求时,会向以太网网络上的一组主机都发送请求。
概念 | byte1 | byte2 | byte3 | byte4 | 可视化的值 |
---|---|---|---|---|---|
IP 地址 | 11000000 | 10101000 | 00000000 | 00000001 | 192.168.0.1/27 |
子网掩码 | 11111111 | 11111111 | 11111111 | 11100000 | 255.255.255.224 或表示为 255.255.255.224/27 |
网络地址 | 11000000 | 10101000 | 00000000 | 00000000 | 192.168.0.0 |
广播地址 | 11000000 | 10101000 | 00000000 | 00011111 | 192.168.0.31 |
上面计算出有 8 个子网,那么 192.168.0.1 则落在第一个可用子网内 192.168.0.1 ~ 192.168.0.30,每个子网有 32 个 IP(由前文广播地址末尾的 11111 决定的),子网分布如下表:
子网 | IP 网段 | 可用主机 |
---|---|---|
一 | 192.168.0.0 ~ 192.168.0.31 | 192.168.0.1 ~ 192.168.0.30 |
二 | 192.168.0.32 ~ 192.168.0.63 | 192.168.0.33 ~ 192.168.0.62 |
三 | 192.168.0.64 ~ 192.168.0.95 | 192.168.0.65 ~ 192.168.0.94 |
四 | 192.168.0.96 ~ 192.168.0.127 | 192.168.0.97 ~ 192.168.0.126 |
五 | 192.168.0.128 ~ 192.168.0.159 | 192.168.0.129 ~ 192.168.0.158 |
六 | 192.168.0.160 ~ 192.168.0.191 | 192.168.0.161 ~ 192.168.0.190 |
七 | 192.168.0.192 ~ 192.168.0.223 | 192.168.0.193 ~ 192.168.0.222 |
八 | 192.168.0.224 ~ 192.168.0.255 | 192.168.0.225 ~ 192.168.0.254 |
二、socket
下图即为 client 和 server 端握手、通信、挥手的过程,这些 connect、accppt、read、write 等都是通过 socket 概念来实现的:
2.1 sockaddr 格式
sockaddr 格式如下:
typedef unsigned short int sa_family_t; // POSIX.1g 规范规定了地址族为 2 字节的值
struct sockaddr { // 描述通用套接字地址sa_family_t sa_family; // 地址族. 16-bitchar sa_data[14]; // 具体的地址值 112-bit
};
其中 sa_family
是地址族,表示对地址解释和保存的方式,在 <sys/socket.h>
定义如下:
// 各种地址族的宏定义,AF 表示 Address Family,PF 表示 Protocal Family,二者是一一对应的,如下:
#define AF_UNSPEC PF_UNSPEC
#define AF_LOCAL PF_LOCAL // 表示本地通信,和 AF_UNIX、AF_FILE 含义相同
#define AF_UNIX PF_UNIX
#define AF_FILE PF_FILE
#define AF_INET PF_INET // 表示IPv4
#define AF_AX25 PF_AX25
#define AF_IPX PF_IPX
#define AF_APPLETALK PF_APPLETALK
#define AF_NETROM PF_NETROM
#define AF_BRIDGE PF_BRIDGE
#define AF_ATMPVC PF_ATMPVC
#define AF_X25 PF_X25
#define AF_INET6 PF_INET6 // 表示IPv6
有 IPv4、IPv6、本地地址三种协议,对比如下图:
sockaddr 是通用地址格式,其通常是函数参数,实现上再通过其前 16bit 的 Family 字段,判断其类型为 sockaddr_in、sockaddr_in6、sockaddr_un 中的哪一种。所以它不需要设计那么长,只需要和最短的IPV4保持一致即可。(通用网络地址结构是所有具体地址结构的抽象,有了统一可以操作的地址结构,那么就可以涉及一套统一的接口,简化了接口设计。通用地址结构中第一个字段表明了地址的类型,后面的数据可以通过具体类型解析出来,一般只有将具体地址类型的指针强制转化成通用类型,这样操作才不会造成内存越界。)
2.1.1 IPv4 sockaddr 格式
// IPv4 套接字地址,32bit 值
typedef uint32_t in_addr_t;
struct in_addr {in_addr_t s_addr;
};// 描述 IPv4 的套接字地址格式
struct sockaddr_in {sa_family_t sin_family; // 16-bit, 为 "AF_INET" 常量, 表示 IPv4in_port_t sin_port; // 端口, 16-bit, 即最多2^16=65536个端口, 通常为了防止冲突,大于 5000 的端口号可供应用程序使用struct in_addr sin_addr; // Internet address, 32-bitunsigned char sin_zero[8]; // 这里仅仅用作占位符, 不做实际用处
};// glibc定义的保留端口(Standard well-known ports)如下:
enum{IPPORT_ECHO = 7, /* Echo service. */IPPORT_DISCARD = 9, /* Discard transmissions service. */IPPORT_SYSTAT = 11, /* System status service. */IPPORT_DAYTIME = 13, /* Time of day service. */IPPORT_NETSTAT = 15, /* Network status service. */IPPORT_FTP = 21, /* File Transfer Protocol. */IPPORT_TELNET = 23, /* Telnet protocol. */IPPORT_SMTP = 25, /* Simple Mail Transfer Protocol. */IPPORT_TIMESERVER = 37, /* Timeserver service. */IPPORT_NAMESERVER = 42, /* Domain Name Service. */IPPORT_WHOIS = 43, /* Internet Whois service. */IPPORT_MTP = 57,IPPORT_TFTP = 69, /* Trivial File Transfer Protocol. */IPPORT_RJE = 77,IPPORT_FINGER = 79, /* Finger service. */IPPORT_TTYLINK = 87,IPPORT_SUPDUP = 95, /* SUPDUP protocol. */IPPORT_EXECSERVER = 512, /* execd service. */IPPORT_LOGINSERVER = 513, /* rlogind service. */IPPORT_CMDSERVER = 514,IPPORT_EFSSERVER = 520,/* UDP ports. */IPPORT_BIFFUDP = 512,IPPORT_WHOSERVER = 513,IPPORT_ROUTESERVER = 520,/* Ports less than this value are reserved for privileged processes. */IPPORT_RESERVED = 1024,/* Ports greater this value are reserved for (non-privileged) servers. */IPPORT_USERRESERVED = 5000
}
2.1.2 IPv6 sockaddr 格式
实际的 IPv4 地址是一个 32-bit 的字段,可以想象最多支持的地址数就是 2 的 32 次方,大约是 42 亿,应该说这个数字在设计之初还是非常巨大的,无奈互联网蓬勃发展,全球接入的设备越来越多,这个数字渐渐显得不太够用了,于是大家所熟知的 IPv6 就隆重登场了。
struct sockaddr_in6 {sa_family_t sin6_family; // 16-bit, 为"AF_INET6"常量, 表示 IPv6in_port_t sin6_port; // 传输端口号 16-bituint32_t sin6_flowinfo; // IPv6 流控信息 32-bitstruct in6_addr sin6_addr; // IPv6 地址 128-bituint32_t sin6_scope_id; // IPv6 域ID 32-bit
};
整个结构体长度是 28 个字节
- 其中流控信息和域 IP 先不用管,这两个字段,一个在 glibc 的官网上根本没出现,另一个是当前未使用的字段。
- 端口同 IPv4 地址一样,关键的地址从 32 位升级到 128 位,这个数字就大到恐怖了,完全解决了寻址数字不够的问题。
2.1.3 本地 sockaddr 格式
要与外部通信,肯定要至少告诉计算机对方的地址和使用的是哪一种地址。与远程计算机的通信还需要一个端口号。远程socket是直接将一段字节流发送到远程计算机的一个进程,而远程计算机可能同时有多个进程在监听,所以用端口号标定要发给哪一个进程。
AF_LOCAL 是本地套接字格式,用来做为本地进程间的通信。本地socket本质上是在访问本地的文件系统,所以自然不需要端口。
路径名长度是可变的,如 /var/a.sock, /var/lib/a.sock 等
struct sockaddr_un {unsigned short sun_family; // 为 "AF_LOCAL" 常量char sun_path[108]; // 路径名
};
如果同一台机器上运行了两个程序,比如redis server 和redis client ,我们在建立 client 到 server 的时候也是要指定端口号的。因为这还是走的网络通信协议栈,只不过是通过本地localhost(127.0.0.1)来进行通信。
2.2 http 与 websocket
Http是应用层协议,是基于Tcp socket的实现,websocket是http的增强,利用了Tcp双向的特性,增强了 server 到 client 的传输能力
以前 client 是需要不断通过轮询来从 server 得到信息,使用websocket以后就可以 server 直接推送信息到 client
三、TCP 编程
项目源码-c语言
tcp server 与 client 项目源码-c语言
3.1 server 端
server 端,准备连接的过程如下:
3.1.1 socket() 创建套接字
- domain:PF_INET、PF_INET6、PF_LOCAL
- type:SOCK_STREAM 是 TCP、SOCK_DGRAM 是 UDP、SOCK_RAW 是原始套接字
- protocol:已废弃,一般填 0
int socket(int domain, int type, int protocol)
3.1.2 bind() 设定电话号码
创建出来的套接字如果需要被别人使用,就需要调用 bind 函数把套接字和套接字地址绑定,就像去电信局登记我们的电话号码一样。
bind(int fd, sockaddr *addr, socklen_t len)
其中 sockaddr * addr
是通用地址格式,可理解为 void* addr
虽然接收的是通用地址格式,实际上传入的参数可能是 IPv4、IPv6 或者本地套接字格式。其中 len
是传入的地址长度,它是一个可变值,用它解析 addr。使用方式如下:
struct sockaddr_in name; // IPv4 格式
bind (sock, (struct sockaddr *)&name, sizeof(name)) // 转为通用格式
server 将仅处理设置的 addr 地址,例如若某机器有两块网卡(IP 分别为 202.61.22.55 和 192.168.1.11),若希望我们的 server 程序可同时处理此两个 IP 的请求,可配置为「通配地址」。
「通配地址」配置方式为:
- 地址:若 IPv4 则设置为 INADDR_ANY,若 IPv6 则设置为 IN6ADDR_ANY
struct sockaddr_in name;
name.sin_addr.s_addr = htonl(INADDR_ANY); // IPV4 通配地址
- 端口:通常指定,否则若指定为 0 则操作系统随机选一个空闲端口
示例如下:
// https://github.com/datager/yolanda/blob/master/chap-4/make_socket.c
#include <stdio.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <netinet/in.h>int make_socket(uint16_t port) {int sock;struct sockaddr_in name;sock = socket(PF_INET, SOCK_STREAM, 0); // 创建字节流类型的IPV4 socketif (sock < 0) {perror("socket");exit(EXIT_FAILURE);}// 绑定到port和ipname.sin_family = AF_INET; // IPV4name.sin_port = htons (port); // 指定端口name.sin_addr.s_addr = htonl (INADDR_ANY); // 通配地址if (bind(sock, (struct sockaddr *) &name, sizeof(name)) < 0) { // 把IPV4地址转换成通用地址格式,同时传递长度perror("bind");exit(EXIT_FAILURE);}printf("bind success with sock: %d", sock);return sock;
}int main(int argc, char **argv) {int sockfd = make_socket(12345);exit(0);
}// 输出如下
root@node:/home# gcc a.c
root@node:/home# ./a.out
bind success with sock: 3
3.1.3 listen() 接上电话线,一切准备就绪
bind() 只是让我们的套接字和地址关联,如同登记了电话号码。如果要让别人打通电话,还需要我们把电话设备接入电话线,让服务器真正处于可接听的状态,这个过程需要依赖 listen()。
初始化创建的套接字,可以认为是一个"主动"套接字,其目的是之后主动发起请求(通过调用 connect 函数,后面会讲到)。通过 listen 函数,可以将原来的"主动"套接字转换为"被动"套接字,告诉操作系统内核:“我这个套接字是用来等待用户请求的。”当然,操作系统内核会为此做好接收用户请求的一切准备,比如完成连接队列。
int listen(int socketfd, int backlog)
- 第一个参数 socketfd 为套接字描述符
- 第二个参数 backlog,官方的解释为未完成连接队列的大小,这个参数的大小决定了可以接收的并发数目。这个参数越大,并发数目理论上也会越大。但是参数过大也会占用过多的系统资源,一些系统,比如 Linux 并不允许对这个参数进行改变。
3.1.4 accept() 电话铃响起了
当 client 的连接请求到达时, server 应答成功,连接建立,这个时候操作系统内核需要把这个事件通知到应用程序,并让应用程序感知到这个连接。这个过程,就好比电信运营商完成了一次电话连接的建立, 应答方的电话铃声响起,通知有人拨打了号码,这个时候就需要拿起电话筒开始应答。
int accept(int listensockfd, struct sockaddr *cliaddr, socklen_t *addrlen)
- 第一个参数 listensockfd 是套接字,可以叫它为 listen 套接字,因为这就是前面通过 bind,listen 一系列操作而得到的套接字。
- 函数的返回值有两个部分:
- 第一个部分
- cliaddr 是通过指针方式获取的 client 的地址
- addrlen 告诉我们地址的大小
- 这两个参数可理解成当我们拿起电话机时,看到了来电显示,知道了对方的号码
- 另一个部分是函数的返回值,这个返回值是一个全新的描述字,代表了与 client 的连接
- 第一个部分
此函数有有两个套接字描述字,第一个是监听套接字描述字 listensockfd (它作为输入参数),第二个是返回的已连接套接字描述字。你可能会问,为什么要把两个套接字分开呢?用一个不是挺好的么?
- 这里和打电话的情形非常不一样的地方就在于,打电话一旦有一个连接建立,别人是不能再打进来的,只会得到语音播报:“您拨的电话正在通话中。”而网络程序的一个重要特征就是「并发」处理,不可能一个应用程序运行之后只能服务一个客户,如果是这样, 双 11 抢购得需要多少服务器才能满足全国 “剁手党 ” 的需求?
- 所以监听套接字一直都存在,它是要为成千上万的客户来服务的,直到这个监听套接字关闭;
- 而一旦一个客户和服务器连接成功,完成了 TCP 三次握手,操作系统内核就为这个客户生成一个「已连接套接字」,让应用服务器使用这个已连接套接字和客户进行通信处理。
- 如果应用服务器完成了对这个客户的服务,比如一次网购下单,一次付款成功,那么关闭的就是「已连接套接字」,这样就完成了 TCP 连接的释放。请注意,这个时候释放的只是这一个客户连接,其它被服务的客户连接可能还存在。最重要的是,监听套接字一直都处于“监听”状态,等待新的客户请求到达并服务。
server 端完整代码如下:
// https://github.com/datager/yolanda/blob/master/chap-5/tcp_server.c
#include "lib/common.h"size_t readn(int fd, void *buffer, size_t size) {char *buffer_pointer = buffer;int length = size;while (length > 0) {int result = read(fd, buffer_pointer, length);if (result < 0) {if (errno == EINTR)continue; /* 考虑非阻塞的情况,这里需要再次调用read */elsereturn (-1);} else if (result == 0)break; /* EOF(End of File)表示套接字关闭 */length -= result;buffer_pointer += result;}return (size - length); /* 返回的是实际读取的字节数*/
}void read_data(int sockfd) {ssize_t n;char buf[1024];int time = 0;for (;;) {fprintf(stdout, "block in read\\n");if ((n = readn(sockfd, buf, 1024)) == 0)return;time++;fprintf(stdout, "1K read for %d \\n", time);usleep(1000);}
}int main(int argc, char **argv) {int listenfd, connfd;socklen_t clilen;struct sockaddr_in cliaddr, servaddr;listenfd = socket(AF_INET, SOCK_STREAM, 0);bzero(&servaddr, sizeof(servaddr));servaddr.sin_family = AF_INET;servaddr.sin_addr.s_addr = htonl(INADDR_ANY);servaddr.sin_port = htons(12345);bind(listenfd, (struct sockaddr *) &servaddr, sizeof(servaddr)); // bind到本地地址,端口为12345listen(listenfd, 1024); // listen的backlog为1024for (;;) { // 循环处理用户请求clilen = sizeof(cliaddr);connfd = accept(listenfd, (struct sockaddr *) &cliaddr, &clilen);read_data(connfd); // 读取数据close(connfd); // 关闭连接套接字,注意不是监听套接字}
}
3.2 client 端
client 端发起连接请求的过程如下:
- 第一步还是和 server 一样,要建立一个套接字,方法和前面是一样的。
- 不一样的是 client 需要调用 connect 向 server 发起请求。
3.2.1 connect() 拨打电话
int connect(int sockfd, const struct sockaddr *servaddr, socklen_t addrlen)
- 函数的第一个参数 sockfd 是连接套接字,通过前面讲述的 socket 函数创建。
- 第二个、第三个参数 servaddr 和 addrlen 分别代表指向套接字地址结构的指针和该结构的大小。套接字地址结构必须含有服务器的 IP 地址和端口号。
客户在调用函数 connect 前不必非得调用 bind 函数,因为如果需要的话,内核会确定源 IP 地址,并按照一定的算法选择一个临时端口作为源端口。
client 端代码如下:
// https://github.com/datager/yolanda/blob/master/chap-5/tcpclient.c
#include "lib/common.h"#define MESSAGE_SIZE 102400void send_data(int sockfd) {char *query;query = malloc(MESSAGE_SIZE + 1);for (int i = 0; i < MESSAGE_SIZE; i++) {query[i] = 'a';}query[MESSAGE_SIZE] = '\\0';const char *cp;cp = query;size_t remaining = strlen(query);while (remaining) {int n_written = send(sockfd, cp, remaining, 0);fprintf(stdout, "send into buffer %ld \\n", n_written);if (n_written <= 0) {error(1, errno, "send failed");return;}remaining -= n_written;cp += n_written;}return;
}int main(int argc, char **argv) {int sockfd;struct sockaddr_in servaddr;if (argc != 2)error(1, 0, "usage: tcpclient <IPaddress>");sockfd = socket(AF_INET, SOCK_STREAM, 0);bzero(&servaddr, sizeof(servaddr));servaddr.sin_family = AF_INET;servaddr.sin_port = htons(12345);inet_pton(AF_INET, argv[1], &servaddr.sin_addr); // 把ip地址转化为用于网络传输的二进制数值: 函数名的p和n分别代表表达(presentation)和数值(numeric)int connect_rt = connect(sockfd, (struct sockaddr *) &servaddr, sizeof(servaddr));if (connect_rt < 0) {error(1, errno, "connect failed ");}send_data(sockfd);exit(0);
}
3.3 三次握手
三次本质是, 信道不可靠, 但是通信双发需要就某个问题达成一致. 而要解决这个问题, 无论你在消息中包含什么信息, 三次通信是理论上的最小值. 所以三次握手不是TCP本身的要求, 而是为了满足"在不可靠信道上可靠地传输信息"这一需求所导致的。
如果是 TCP 套接字,那么调用 connect 函数将激发 TCP 的三次握手过程,而且仅在连接建立成功或出错时才返回。其中出错返回可能有以下几种情况:
- 三次握手无法建立, client 发出的 SYN 包没有任何响应,于是返回 TIMEOUT 错误。这种情况比较常见的原因是对应的 server IP 写错。
- client 收到了 RST(复位)回答,这时候 client 会立即返回 CONNECTION REFUSED 错误。这种情况比较常见于 client 发送连接请求时的请求端口写错,因为 RST 是 TCP 在发生错误时发送的一种 TCP 分节。产生 RST 的三个条件是:目的地为某端口的 SYN 到达,然而该端口上没有正在监听的服务器(如前所述);TCP 想取消一个已有连接;TCP 接收到一个根本不存在的连接上的分节。
- 客户发出的 SYN 包在网络上引起了"destination unreachable",即目的不可达的错误。这种情况比较常见的原因是 client 和 server 路由不通。
下文介绍的是阻塞式编程模型(即调用发起后不会直接返回,由操作系统内核处理之后才会返回):
- 首先 server 通过 socket,bind 和 listen 完成了被动套接字的准备工作,被动的意思就是等着别人来连接,然后调用 accept,就会阻塞在这里,等待 client 的连接来临;
- client 通过调用 socket 和 connect 函数之后,也会阻塞。
- 接下来的事情是由操作系统内核完成的,更具体一点的说,是操作系统内核网络协议栈在工作。
下面是具体的过程:
- client 的协议栈向 server 发送了 SYN 包,并告诉 server 当前发送序列号 j, client 进入 SYNC_SENT 状态;
- server 的协议栈收到这个包之后,和 client 进行 ACK 应答,应答的值为 j+1,表示对 SYN 包 j 的确认,同时服务器也发送一个 SYN 包,告诉 client 当前我的发送序列号为 k, server 进入 SYNC_RCVD 状态;
- client 协议栈收到 ACK 之后,使得应用程序从
connect 阻塞调用返回
,表示 client 到 server 的单向连接建立成功, client 的状态为 ESTABLISHED,同时 client 协议栈也会对 server 的 SYN 包进行应答,应答数据为 k+1; - 应答包到达 server 后, server 协议栈使得
accept 阻塞调用返回
,这个时候 server 到 client 的单向连接也建立成功, server 也进入 ESTABLISHED 状态。
形象一点的比喻是这样的,有 A 和 B 想进行通话:
- A 先对 B 说:“喂,你在么?我在的,我的口令是 j。”
- B 收到之后大声回答:“我收到你的口令 j 并准备好了,你准备好了吗?我的口令是 k。”
- A 收到之后也大声回答:“我收到你的口令 k 并准备好了,我们开始吧。”
可以看到,这样的应答过程总共进行了三次,这就是 TCP 连接建立之所以被叫为“三次握手”的原因了。
3.4 read/write 数据
套接字描述本身和本地文件描述符并无区别,在 UNIX 的世界里万物都是文件,这就意味着可以将套接字描述符传递给那些原先为处理本地文件而设计的函数。这些函数包括 write 和 read 交换数据的函数:
3.4.1 write
下述三个函数可以发送:
ssize_t write(int socketfd, const void *buffer, size_t size)
ssize_t send(int socketfd, const void *buffer, size_t size, int flags)
ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags)
- 第一个函数是常见的文件写函数,如果把 socketfd 换成文件描述符,就是普通的文件写入。
- 如果想指定选项,发送带外数据,就需要使用第二个带 flag 的函数。所谓带外数据,是一种基于 TCP 协议的紧急数据,用于 client - 服务器在特定场景下的紧急处理。
- 如果想指定多重缓冲区传输数据,就需要使用第三个函数,以结构体 msghdr 的方式发送数据。
write()
函数可以写文件和网络,但效果略有不同:
- 对于普通文件描述符而言,一个文件描述符代表了打开的一个文件句柄,通过调用 write 函数,操作系统内核帮我们不断地往文件系统中写入字节流。注意,写入的字节流大小通常和输入参数 size 的值是相同的,否则表示出错。
- 对于套接字描述符而言,它代表了一个双向连接,在套接字描述符上调用 write 写入的字节数有可能比请求的数量少,这在普通文件描述符情况下是不正常的。
- 产生这个现象的原因在于操作系统内核为读取和发送数据做了很多我们表面上看不到的工作。接下来我拿 write 函数举例,重点阐述发送缓冲区的概念。
3.4.1.1 发送缓冲区
当 TCP 三次握手成功,TCP 连接成功建立后,操作系统内核会为每一个连接创建配套的基础设施,比如发送缓冲区。
发送缓冲区的大小可以通过套接字选项来改变,当我们的应用程序调用 write 函数时,实际所做的事情是把数据从应用程序中拷贝到操作系统内核的发送缓冲区中,并不一定是把数据通过套接字写出去。
这里有几种情况:
- 第一种情况很简单,操作系统内核的发送缓冲区足够大,可以直接容纳这份数据,那么皆大欢喜,我们的程序从 write 调用中退出,返回写入的字节数就是应用程序的数据大小。
- 第二种情况是,操作系统内核的发送缓冲区是够大了,不过还有数据没有发送完,或者数据发送完了,但是操作系统内核的发送缓冲区不足以容纳应用程序数据,在这种情况下,你预料的结果是什么呢?报错?还是直接返回?
- 操作系统内核并不会返回,也不会报错,而是应用程序被阻塞,也就是说应用程序在 write 函数调用处停留,不直接返回。术语“挂起”也表达了相同的意思,不过“挂起”是从操作系统内核角度来说的。
- 那什么时候才会返回呢?实际上,每个操作系统内核的处理是不同的。大部分 UNIX 系统的做法是一直等到可以把应用程序数据完全放到操作系统内核的发送缓冲区中,再从系统调用中返回。怎么理解呢?别忘了,我们的操作系统内核是很聪明的,当 TCP 连接建立之后,它就开始运作起来。你可以把发送缓冲区想象成一条包裹流水线,有个聪明且忙碌的工人不断地从流水线上取出包裹(数据),这个工人会按照 TCP/IP 的语义,将取出的包裹(数据)封装成 TCP 的 MSS 包,以及 IP 的 MTU 包,最后走数据链路层将数据发送出去。这样我们的发送缓冲区就又空了一部分,于是又可以继续从应用程序搬一部分数据到发送缓冲区里,这样一直进行下去,到某一个时刻,应用程序的数据可以完全放置到发送缓冲区里。在这个时候,write 阻塞调用返回。注意返回的时刻,应用程序数据并没有全部被发送出去,发送缓冲区里还有部分数据,这部分数据会在稍后由操作系统内核通过网络发送出去。
3.4.2 read
read 函数要求操作系统内核从套接字描述字 socketfd 读取最多 多少个字节(size),并将结果存储到 buffer 中。返回值告诉我们实际读取的字节数目,也有一些特殊情况:
- 如果返回值为 0,表示 EOF(end-of-file),这在网络中表示对端发送了 FIN 包,要处理断连的情况
- 如果返回值为 -1,表示出错
- 当然,如果是非阻塞 I/O,情况会略有不同,在后面的提高篇中我们会重点讲述非阻塞 I/O 的特点。
ssize_t read (int socketfd, void *buffer, size_t size)
注意这里是最多读取 size 个字节。如果我们想让应用程序每次都读到 size 个字节,就需要编写下面的函数,不断地循环读取:
// 从 socketfd 描述字中读取 "size" 个字节
ssize_t readn(int fd, void *vptr, size_t size) {size_t nleft;ssize_t nread;char *ptr;ptr = vptr;nleft = size;while (nleft > 0) { // 在没读满 size 个字节之前,一直都要循环下去if ( (nread = read(fd, ptr, nleft)) < 0) {if (errno == EINTR) // 非阻塞 I/O 的情况下,没有数据可以读,需要继续调用 readnread = 0;elsereturn(-1);} else if (nread == 0) // 读到对方发出的 FIN 包,表现形式是 EOF,此时需要关闭套接字break;nleft -= nread; ptr += nread; // 需要读取的字符数减少,缓存指针往下移动。}return(n - nleft); // 读取 EOF 跳出循环后,返回实际读取的字符数
}
3.4.3 缓冲区实验
我们用一个 client - 服务器的例子来解释一下读取缓冲区和发送缓冲区的概念。在这个例子中 client 不断地发送数据, server 每读取一段数据之后进行休眠,以模拟实际业务处理所需要的时间。代码详见 3.1 和 3.2 节(https://github.com/datager/yolanda/blob/master/chap-5/)。其效果如下:
实验一: 观察 client 数据发送行为
- client 程序发送了一个很大的字节流(
define MESSAGE_SIZE 102400
),其直到最后所有的字节流发送完毕才打印出fprintf(stdout, "send into buffer %ld \\n", n_written);
,说明在此之前send()
一直都是阻塞的,即阻塞式套接字最终发送返回的实际写入字节数和请求字节数是相等的。 - server 不断地在屏幕上打印出读取字节流的过程
实验二: server 处理变慢
- 如果我们把 server 的休眠时间稍微调大,把 client 发送的字节数从 10240000 调整为 1024000,再次运行刚才的例子我们会发现 client 很快打印。
- 但与此同时,server 读取程序还在屏幕上不断打印读取数据的进度,显示出 client 读取程序还在辛苦地从
缓冲区
中读取数据。
结论:
- 发送成功仅仅表示的是数据被拷贝到了发送缓冲区中,并不意味着连接对端已经收到所有的数据。
- 至于什么时候发送到对端的接收缓冲区,或者更进一步说,什么时候被对方应用程序缓冲所接收,对我们而言完全都是透明的。
- 无限增大缓冲区肯定不行:因为 write() 函数发送数据只是将数据发送到内核缓冲区,而什么时候发送由内核决定。
- 内核缓冲区总是充满数据时会产生粘包问题
- 网络的传输大小 MTU 也会限制每次发送的大小
- 由于数据堵塞需要消耗大量内存资源,资源使用效率不高。
四、UDP 编程
TCP 是面向连接的 “数据流” 协议,UDP 是 “数据报” 协议。
- TCP 类似于打电话:拨打号码,接通电话,开始交流,分别对应了 TCP 的三次握手和报文传送。一旦双方的连接建立,那么双方对话时,一定知道彼此是谁。这个时候我们就说,这种对话是有上下文的。
- UDP 类似于寄明信片:发信方在明信片中填上了接收方的地址和邮编,投递到邮局的邮筒之后,就可以不管了。
- 发信方也可以给这个接收方再邮寄第二张、第三张,甚至是第四张明信片,但是这几张明信片之间是没有任何关系的,他们的到达顺序也是不保证的,有可能最后寄出的第四张明信片最先到达接收者的手中,因为没有序号,接收者也不知道这是第四张寄出的明信片;
- 而且,即使接收方没有收到明信片,也没有办法重新邮寄一遍该明信片。
具体区别如下:
- TCP 是一个面向连接的协议,TCP 在 IP 报文的基础上,增加了诸如重传、确认、有序传输、拥塞控制等能力,通信的双方是在一个确定的上下文中工作的
- UDP 没有这样一个确定的上下文,它是一个不可靠的通信协议,没有重传和确认,没有有序控制,也没有拥塞控制。我们可以简单地理解为,在 IP 报文的基础上,UDP 增加的能力有限。UDP 不保证报文的有效传递,不保证报文的有序,也就是说使用 UDP 时要应用自己做丢包、重传、报文组装等工作。
- TCP 的发送和接收每次都是在一个上下文中,类似这样的过程:
- A 连接上: 接收→发送→接收→发送→…
- B 连接上: 接收→发送→接收→发送→ …
- 而 UDP 的每次接收和发送都是一个独立的上下文,类似这样:
- 接收 A→发送 A→接收 B→发送 B →接收 C→发送 C→ …
因为 UDP 比较简单,适合的场景还是比较多的:
- DNS 服务,SNMP 服务
- 多人通信的场景,如聊天室、多人游戏等
UDP 程序的过程如下:
recvfrom() 定义如下:
- sockfd、buff 和 nbytes 是前三个参数。sockfd 是本地创建的套接字描述符,buff 指向本地的缓存,nbytes 表示最大接收数据字节。
- 第四个参数 flags 是和 I/O 相关的参数,这里我们还用不到,设置为 0。
- 后面两个参数 from 和 addrlen,实际上是返回对端发送方的地址和端口等信息,这和 TCP 非常不一样,TCP 是通过 accept() 拿到的描述字信息来决定对端的信息。而 UDP 报文每次接收都会获取对端的信息,即说报文和报文之间没有上下文。
- 函数的返回值:实际接收的字节数。
#include <sys/socket.h>
ssize_t recvfrom(int sockfd, void *buff, size_t nbytes, int flags, struct sockaddr *from, socklen_t *addrlen);
sendto() 函数定义如下:
- 前三个参数为 sockfd、buff 和 nbytes。sockfd 是本地创建的套接字描述符,buff 指向发送的缓存,nbytes 表示发送字节数。
- 第四个参数 flags 依旧设置为 0。
- 后面两个参数 to 和 addrlen,表示发送的对端地址和端口等信息。
- 函数的返回值:实际发送的字节数。
- 最大能发送数据的长度为:65535- IP头(20) - UDP头(8)=65507字节。用sendto函数发送数据时,如果发送数据长度大于该值,则函数会返回错误。
- 又因为 IP 有最大 MTU,因此
- UDP 包的大小应该是 1500 - IP头(20) - UDP头(8) = 1472(Bytes)
- TCP 包的大小应该是 1500 - IP头(20) - TCP头(20) = 1460(Bytes)
- 又因为 IP 有最大 MTU,因此
#include <sys/socket.h>
ssize_t sendto(int sockfd, const void *buff, size_t nbytes, int flags,const struct sockaddr *to, socklen_t *addrlen);
4.1 server 端
// https://github.com/datager/yolanda/blob/master/chap-6/udpserver.c
#include "lib/common.h"static int count;
static void recvfrom_int(int signo) {printf("\\nreceived %d datagrams\\n", count);exit(0);
}int main(int argc, char **argv) {int socket_fd;socket_fd = socket(AF_INET, SOCK_DGRAM, 0); // 创建udp类型(SOCK_DGRAM) 的 socketstruct sockaddr_in server_addr;bzero(&server_addr, sizeof(server_addr));server_addr.sin_family = AF_INET;server_addr.sin_addr.s_addr = htonl(INADDR_ANY);server_addr.sin_port = htons(SERV_PORT);bind(socket_fd, (struct sockaddr *) &server_addr, sizeof(server_addr)); // 绑定到本地端口上socklen_t client_len;char message[MAXLINE];count = 0;signal(SIGINT, recvfrom_int); // 创建了一个信号处理函数,以便在响应“Ctrl+C”退出时,打印出收到的报文总数struct sockaddr_in client_addr;client_len = sizeof(client_addr);for (;;) {int n = recvfrom(socket_fd, message, MAXLINE, 0, (struct sockaddr *) &client_addr, &client_len); // 接收message[n] = 0;printf("received %d bytes: %s\\n", n, message);char send_line[MAXLINE];sprintf(send_line, "Hi, %s", message);sendto(socket_fd, send_line, strlen(send_line), 0, (struct sockaddr *) &client_addr, client_len); // 发送count++;}
}
4.2 client 端
在这个例子中,从 stdin 读取输入的字符串后,发送给 server,并且把 server 经过处理的报文打印到 stdout 上。
// https://github.com/datager/yolanda/blob/master/chap-6/udpclient.c
#include "lib/common.h"
#define MAXLINE 4096int main(int argc, char **argv) {if (argc != 2) {error(1, 0, "usage: udpclient <IPaddress>");}int socket_fd;socket_fd = socket(AF_INET, SOCK_DGRAM, 0); // 创建udp类型(SOCK_DGRAM) 的 socket// 初始化: 目标地址server_addr.sin_addr 和 端口server_addr.sin_portstruct sockaddr_in server_addr;bzero(&server_addr, sizeof(server_addr));server_addr.sin_family = AF_INET;server_addr.sin_port = htons(SERV_PORT);inet_pton(AF_INET, argv[1], &server_addr.sin_addr);socklen_t server_len = sizeof(server_addr);struct sockaddr *reply_addr; // 用于接收reply_addr = malloc(server_len);char send_line[MAXLINE], recv_line[MAXLINE + 1];socklen_t len;int n;while (fgets(send_line, MAXLINE, stdin) != NULL) { // 从 stdin 读取的字符int i = strlen(send_line);if (send_line[i - 1] == '\\n') {send_line[i - 1] = 0;}printf("now sending %s\\n", send_line);size_t rt = sendto(socket_fd, send_line, strlen(send_line), 0, (struct sockaddr *) &server_addr, server_len); // 发送if (rt < 0) {error(1, errno, "send failed ");}printf("send bytes: %zu \\n", rt);len = 0;n = recvfrom(socket_fd, recv_line, MAXLINE, 0, reply_addr, &len); // 接收if (n < 0)error(1, errno, "recvfrom failed");recv_line[n] = 0;fputs(recv_line, stdout); // 打印到 stdoutfputs("\\n", stdout);}exit(0);
}
4.3 实验
更好地理解 UDP 和 TCP 之间的差别,我们模拟一下 UDP 的三种运行场景,你不妨思考一下这三种场景的结果和 TCP 的到底有什么不同?
场景一:只运行 client
- 如果我们只运行 client ,先在 stdin 输入 “1” 则会成功调用 sendto(),然后就一直阻塞在 recvfrom() 上。
- 还记得 TCP 程序吗?如果不开启 server ,TCP client 的 connect 函数会直接返回 “Connection refused” 报错信息(此信息是对方操作系统内核的TCP 协议栈发送的,而不是对方未启动的 server 发送的)。而在 UDP 程序里,则会一直阻塞在这里。
- 默认这种阻塞行为是不合理的,我们可以添加超时时间做处理,当然可以自己实现一个复杂的请求-确认模式,那这样就跟 TCP 类似了,HTTP/3 就是这样做的。
场景二:先开启 server ,再开启 client
- 在这个场景里,我们先开启 server 在端口侦听,然后再开启 client :
- 我们在 client 一次输入 g1、g2, server 在屏幕上打印出收到的字符,并且可以看到,我们的 client 也收到了 server 的回应:“Hi, g1”和“Hi,g2”。
场景三: 开启 server ,再一次开启两个 client
- 这个实验中,在 server 开启之后,依次开启两个 client ,并发送报文。我们看到,两个 client 发送的报文,依次都被 server 收到,并且 client 也可以收到 server 处理之后的报文。
如果我们此时把 server 进程杀死,就可以看到信号函数在进程退出之前,打印出 server 接收到的报文个数。
之后,我们再重启 server 进程,并使用 client1 和 client2 继续发送新的报文,我们可以看到和 TCP 非常不同的结果:server 重启后可以继续收到 client 的报文,而 TCP 却不可以,TCP 断联之后必须重新连接才可以发送报文信息。但是 UDP 报文的”无连接“的特点,可以在 UDP 服务器重启之后,继续进行报文的发送,这就是 UDP 报文“无上下文”的最好说明,实验过程如下:
4.4 udp 的 connect()
4.4.1 client 的 connect()
我们先从一个 client 的例子开始,在这个例子中,client 在 UDP 套接字上调用 connect(),之后将标准输入的字符串发送到 server,并从 server 接收处理后的报文。当然,和 server 发送和接收报文是通过调用函数 sendto() 和 recvfrom() 来完成的。
20-22 行调用 connect() 将 UDP 套接字和 IPv4 地址进行了“绑定”,这里 connect() 的名称有点让人误解,其实可能更好的选择是叫做 setpeername();
// 示例代码就是本代码段
#include "lib/common.h"
#define MAXLINE 4096int main(int argc, char **argv) {if (argc != 2) {error(1, 0, "usage: udpclient1 <IPaddress>");}int socket_fd;socket_fd = socket(AF_INET, SOCK_DGRAM, 0);struct sockaddr_in server_addr;bzero(&server_addr, sizeof(server_addr));server_addr.sin_family = AF_INET;server_addr.sin_port = htons(SERV_PORT);inet_pton(AF_INET, argv[1], &server_addr.sin_addr);socklen_t server_len = sizeof(server_addr);if (connect(socket_fd, (struct sockaddr *) &server_addr, server_len)) { // 调用 connect() 将 UDP 套接字和 IPv4 地址进行了“绑定”,这里 connect() 的名称有点让人误解,其实可能更好的选择是叫做 setpeername()error(1, errno, "connect failed");}struct sockaddr *reply_addr;reply_addr = malloc(server_len);char send_line[MAXLINE], recv_line[MAXLINE + 1];socklen_t len;int n;while (fgets(send_line, MAXLINE, stdin) != NULL) {int i = strlen(send_line);if (send_line[i - 1] == '\\n') {send_line[i - 1] = 0;}printf("now sending %s\\n", send_line);size_t rt = sendto(socket_fd, send_line, strlen(send_line), 0, (struct sockaddr *) &server_addr, server_len);if (rt < 0) {error(1, errno, "sendto failed");}printf("send bytes: %zu \\n", rt);len = 0;recv_line[0] = 0;n = recvfrom(socket_fd, recv_line, MAXLINE, 0, reply_addr, &len);if (n < 0)error(1, errno, "recvfrom failed");recv_line[n] = 0;fputs(recv_line, stdout);fputs("\\n", stdout);}exit(0);
}
在没有开启 server 的情况下,运行此 client 效果如下:
和 TCP connect 调用引起 TCP 三次握手建立 TCP 有效连接不同,UDP connect 函数的调用,并不会引起和服务器目标端的网络交互,也就是说,并不会触发所谓的”握手“报文发送和应答。
那么对 UDP 套接字进行 connect() 操作到底有什么意义呢?其实上面的例子已经给出了答案,这主要是为了让应用程序能够接收”异步错误“的信息。
- 如果我们回想一下不调用 connect() 操作的 client(本文4.2节),在 server 不开启的情况下, client 是不会报错的,程序只会阻塞在 recvfrom 上,等待返回(或者超时)。
- 而在这里(本文4.4节),通过对 UDP 套接字进行 connect(),将 UDP 套接字建立了”上下文“,该套接字和 server 的地址和端口产生了联系,正是这种绑定关系给了操作系统内核必要的信息,能够将操作系统内核收到的信息和对应的套接字进行关联。
- 事实上,当我们调用 sendto() 或者 send() 时,应用程序报文被发送,我们的应用程序返回,操作系统内核接管了该报文,之后操作系统开始尝试往对应的地址和端口发送,因为对应的地址和端口不可达,一个 ICMP 报文会返回给操作系统内核,该 ICMP 报文含有目的地址和端口等信息。
- 如果我们不进行 connect() 来建立(「UDP 套接字」 ——「目的地址 + 端口」)之间的映射关系,操作系统内核就没有办法把 ICMP 不可达的信息和 UDP 套接字进行关联,也就没有办法将 ICMP 信息通知给应用程序。
- 如果我们进行了 connect(),帮助操作系统内核从容建立了(「UDP 套接字」 ——「目的地址 + 端口」)之间的映射关系,当收到一个 ICMP 不可达报文时,操作系统内核可以从映射表中找出是哪个 UDP 套接字拥有该目的地址和端口,别忘了套接字在操作系统内部是全局唯一的,当我们在该套接字上再次调用 recvfrom() 或 recv() 时,就可以收到操作系统内核返回的 ”Connection Refused“ 的信息。
在对 UDP 进行 connect() 之后,收发函数建议用 send() 和 recv():
- 用 send() 或 write() 来发送,如果用 sendto() 则需把相关的 to 地址信息置零;
- 用 recv() 或 read() 来接收,如果用 recvfrom() 则需把对应的 from 地址信息置零。
- 但其实不同的 UNIX 实现对此表现出来的行为不尽相同。
- 在我的 Linux 4.4.0 环境中,用 sendto() 和 recvfrom() 系统都会自动忽略 to 和 from 信息。
- 在我的 macOS 10.13 中,确实需要遵守这样的规定:用 sendto() 或 recvfrom() 会得到一些奇怪的结果,而切回 send() 和 recv() 后则正常。
- 结论:考虑到兼容性,我们也推荐这些常规做法,即推荐用 send() 和 recv()。所以在接下来的程序中,我会使用这样的做法来实现。
4.4.2 server 的 connect()
一般来说, server 不会主动发起 connect() 操作,因为一旦如此, server 就只能响应一个 client 了。不过有时候也不排除这样的情形:一旦一个 client 和 server 发送 UDP 报文之后,该 server 就要服务于这个唯一的 client 。
server 如下:39-41 行调用 connect() 操作,将 UDP 套接字和 client_addr 绑定:
// 示例代码就是本代码段
#include "lib/common.h"static int count;static void recvfrom_int(int signo) {printf("\\nreceived %d datagrams\\n", count);exit(0);
}int main(int argc, char **argv) {int socket_fd;socket_fd = socket(AF_INET, SOCK_DGRAM, 0);struct sockaddr_in server_addr;bzero(&server_addr, sizeof(server_addr));server_addr.sin_family = AF_INET;server_addr.sin_addr.s_addr = htonl(INADDR_ANY);server_addr.sin_port = htons(SERV_PORT);bind(socket_fd, (struct sockaddr *) &server_addr, sizeof(server_addr));socklen_t client_len;char message[MAXLINE];message[0] = 0;count = 0;signal(SIGINT, recvfrom_int);struct sockaddr_in client_addr;client_len = sizeof(client_addr);int n = recvfrom(socket_fd, message, MAXLINE, 0, (struct sockaddr *) &client_addr, &client_len);if (n < 0) {error(1, errno, "recvfrom failed");}message[n] = 0;printf("received %d bytes: %s\\n", n, message);if (connect(socket_fd, (struct sockaddr *) &client_addr, client_len)) { // 39-41 行调用 connect(),将 UDP 套接字和 client_addr 绑定error(1, errno, "connect failed");}while (strncmp(message, "goodbye", 7) != 0) {char send_line[MAXLINE];sprintf(send_line, "Hi, %s", message);size_t rt = send(socket_fd, send_line, strlen(send_line), 0);if (rt < 0) {error(1, errno, "send failed ");}printf("send bytes: %zu \\n", rt);size_t rc = recv(socket_fd, message, MAXLINE, 0);if (rc < 0) {error(1, errno, "recv failed");}count++;}exit(0);
}
接下来,我们先启动 server,然后依次开启两个 client(client 如本文4.4.1节),分别是 client1、 client2,并让 client1 先发送 UDP 报文。
我们看到, client1 先发送报文,服务端随之通过 connect 和 client1 进行了“绑定”,这样, client 2 从操作系统内核得到了 ICMP 的错误,该错误在 recv 函数中返回,显示了“Connection refused”的错误信息。
对 UDP 使用 connect() 绑定本地地址和端口,是为了让我们的应用程序可以快速获取异步错误信息的通知,同时也可以获得一定性能上的提升。
- 因为如果不使用 connect() 方式,每次发送报文都会需要这样的过程:连接套接字→发送报文→断开套接字→连接套接字→发送报文→断开套接字 →………
- 而如果使用 connect() 方式,就会变成下面这样:连接套接字→发送报文→发送报文→……→最后断开套接字
- 我们知道,连接套接字是需要一定开销的,比如需要查找路由表信息。所以,UDP client 通过 connect() 可以获得一定的性能提升。
五、本地 socket 编程
本地套接字是 IPC,也就是本地进程间通信的一种实现方式。除了本地套接字以外,其它技术,诸如管道、共享消息队列等也是进程间通信的常用方法,但因为本地套接字开发便捷,接受度高,所以普遍适用于在同一台主机上进程间通信的各种场景。
「本地 socket」 也曾称为「UNIX 域 socket」。
- TCP/UDP:即使设置为 127.0.0.1 在本机通信,也要走网络协议栈
- 本地 socket:是一种单机进程间调用的方式,减少了协议栈实现的复杂度,效率比 TCP/UDP 都高得多。类似的机制还有 UNIX 管道、共享内存、RPC 调用。
本地套接字本质还是进程间通信,只是借助了套接字的编程语义,比如stream和datagram,最下面肯定不走IP协议的。
5.1 本地字节流 socket
「本地字节流套接字」和 TCP server、 client 编程最大的差异就是套接字类型的不同。本地字节流套接字识别 server 时不再通过 IP 地址和端口,而是通过本地文件。
server 端如下:server 打开本地 socket 后,接收 client 发送来的字节流,并向 client 回送了新的字节流。
// https://github.com/datager/yolanda/blob/master/chap-7/unixstreamserver.c
#include "lib/common.h"
int main(int argc, char **argv) {if (argc != 2) {error(1, 0, "usage: unixstreamserver <local_path>");}int listenfd, connfd;socklen_t clilen;struct sockaddr_un cliaddr, servaddr;listenfd = socket(AF_LOCAL, SOCK_STREAM, 0); // TCP 的类型是 AF_INET 和字节流类型;UDP 的类型是 AF_INET 和数据报类型; 本地 socket 是 AF_UNIX(其和 AF_LOCAL 等价)if (listenfd < 0) {error(1, errno, "socket create failed");}// 创建了一个本地地址,这里的本地地址和 IPv4、IPv6 地址可以对应,数据类型为 sockaddr_unchar *local_path = argv[1]; // 必须是绝对路径才能在任意目录启动/管理程序。是文件(而不是目录),用户要有文件的chown/chmod权限unlink(local_path); // 把存在的文件删除掉,来保持幂等性bzero(&servaddr, sizeof(servaddr));servaddr.sun_family = AF_LOCAL;strcpy(servaddr.sun_path, local_path); // 对 sun_path 设置一个本地文件路径if (bind(listenfd, (struct sockaddr *) &servaddr, sizeof(servaddr)) < 0) { // bind(如果文件不存在, bind 会创建此文件)error(1, errno, "bind failed");}if (listen(listenfd, LISTENQ) < 0) { // listenerror(1, errno, "listen failed");}clilen = sizeof(cliaddr);if ((connfd = accept(listenfd, (struct sockaddr *) &cliaddr, &clilen)) < 0) {if (errno == EINTR)error(1, errno, "accept failed"); // back to for()elseerror(1, errno, "accept failed");}char buf[BUFFER_SIZE];while (1) {bzero(buf, sizeof(buf));if (read(connfd, buf, BUFFER_SIZE) == 0) {printf("client quit");break;}printf("Receive: %s", buf);char send_line[MAXLINE];sprintf(send_line, "Hi, %s", buf);int nbytes = sizeof(send_line);if (write(connfd, send_line, nbytes) != nbytes)error(1, errno, "write error");}close(listenfd);close(connfd);exit(0);
}
client 端如下:
// https://github.com/datager/yolanda/blob/master/chap-7/unixstreamclient.c
#include "lib/common.h"int main(int argc, char **argv) {if (argc != 2) {error(1, 0, "usage: unixstreamclient <local_path>");}int sockfd;struct sockaddr_un servaddr;sockfd = socket(AF_LOCAL, SOCK_STREAM, 0);if (sockfd < 0) {error(1, errno, "create socket failed");}bzero(&servaddr, sizeof(servaddr));servaddr.sun_family = AF_LOCAL;strcpy(servaddr.sun_path, argv[1]); // 因为是本地 socket,所以是目标文件路径(而不是 ip 和 port)if (connect(sockfd, (struct sockaddr *) &servaddr, sizeof(servaddr)) < 0) { // 因为是本地 socket,所以内部无三次握手过程error(1, errno, "connect failed");}char send_line[MAXLINE];bzero(send_line, MAXLINE);char recv_line[MAXLINE];while (fgets(send_line, MAXLINE, stdin) != NULL) {int nbytes = sizeof(send_line);if (write(sockfd, send_line, nbytes) != nbytes)error(1, errno, "write error");if (read(sockfd, recv_line, MAXLINE) == 0)error(1, errno, "server terminated prematurely");fputs(recv_line, stdout);}exit(0);
}
接下来,我们就运行这个程序来加深对此的理解。
5.1.1 只启动 client
第一个场景中,我们只启动 client 程序:我们看到,由于没有启动 server ,没有一个本地套接字在 /tmp/unixstream.sock 这个文件上监听, client 直接报错,提示我们没有文件存在。
5.1.2 server 监听在无权限的文件路径上
Linux 下,执行任何应用程序都有应用属主的概念。在这里,我们让 server 程序的应用属主没有 /var/lib/ 目录的权限,然后试着启动一下这个服务器程序,会报错如下 :
$ ./unixstreamserver /var/lib/unixstream.sock
bind failed: Permission denied (13)
这个结果告诉我们启动 server 程序的用户,必须对本地监听路径有权限。
试一下 root 用户启动该程序,我们看到 server 程序正常运行了:
打开另外一个 shell,我们看到 /var/lib
下创建了一个本地文件,大小为 0,而且文件的最后结尾有一个(=)号。其实这就是 bind 的时候自动创建出来的文件:
如果用 netstat 命令查看 UNIX 域套接字,就会发现 unixstreamserver 这个进程,监听在 /var/lib/unixstream.sock 这个文件路径上。如我们所预期,我们写的程序和鼎鼎大名的 Kubernetes 运行在同一机器上,原理和行为完全一致。如下图:
5.1.3 server - client 应答
现在让 server 和 client 都正常启动,并且 client 依次发送字符:
我们可以看到,server 陆续收到 client 发送的字节,同时, client 也收到了 server 的应答;最后,当我们使用 Ctrl+C,让 client 程序退出时,server 也正常退出。
5.2 本地数据报 socket
server 端如下:「本地数据报 socket」 和前面的「本地字节流 socket」有以下几点不同:
- 第 9 行创建的本地套接字,套接字类型是 AF_LOCAL,协议类型为 SOCK_DGRAM。
- 21~23 行 bind() 到本地地址之后,没有再调用 listen() 和 accept(),回忆一下这其实和 UDP 的性质一样。
- 28~45 行用 recvfrom() 和 sendto() 来进行数据报的收发,不再是 read() 和 send(),这其实也和 UDP 网络程序一致。
// https://github.com/datager/yolanda/blob/master/chap-7/unixdataserver.c
#include "lib/common.h"int main(int argc, char **argv) {if (argc != 2) {error(1, 0, "usage: unixdataserver <local_path>");}int socket_fd;socket_fd = socket(AF_LOCAL, SOCK_DGRAM, 0); // AF_LOCAL, SOCK_DGRAMif (socket_fd < 0) {error(1, errno, "socket create failed");}struct sockaddr_un servaddr;char *local_path = argv[1];unlink(local_path);bzero(&servaddr, sizeof(servaddr));servaddr.sun_family = AF_LOCAL;strcpy(servaddr.sun_path, local_path);if (bind(socket_fd, (struct sockaddr *) &servaddr, sizeof(servaddr)) < 0) {error(1, errno, "bind failed");}char buf[BUFFER_SIZE];struct sockaddr_un client_addr;socklen_t client_len = sizeof(client_addr);while (1) {bzero(buf, sizeof(buf));if (recvfrom(socket_fd, buf, BUFFER_SIZE, 0, (struct sockadd *) &client_addr, &client_len) == 0) {printf("client quit");break;}printf("Receive: %s \\n", buf);char send_line[MAXLINE];bzero(send_line, MAXLINE);sprintf(send_line, "Hi, %s", buf);size_t nbytes = strlen(send_line);printf("now sending: %s \\n", send_line);if (sendto(socket_fd, send_line, nbytes, 0, (struct sockadd *) &client_addr, client_len) != nbytes)error(1, errno, "sendto error");}close(socket_fd);exit(0);
}
client 端如下,这个程序和 UDP 网络编程的例子基本是一致的,我们可以把它当做是用本地文件替换了 IP 地址和端口的 UDP 程序,不过还是有一个非常大的不同的。这个不同点就在 16~22 行将本地套接字 bind() 到本地一个路径上,然而 UDP client 程序是不需要这么做的:
- 本地数据报套接字这么做的原因是,它需要指定一个本地路径,以便在 server 回包时,可以正确地找到地址;
- 而 UDP client,数据是可以通过 UDP 包的本地地址和端口来匹配的。
// https://github.com/datager/yolanda/blob/master/chap-7/unixdataclient.c
#include "lib/common.h"int main(int argc, char **argv) {if (argc != 2) {error(1, 0, "usage: unixdataclient <local_path>");}int sockfd;struct sockaddr_un client_addr, server_addr;sockfd = socket(AF_LOCAL, SOCK_DGRAM, 0);if (sockfd < 0) {error(1, errno, "create socket failed");}bzero(&client_addr, sizeof(client_addr)); // bind an address for usclient_addr.sun_family = AF_LOCAL;strcpy(client_addr.sun_path, tmpnam(NULL));if (bind(sockfd, (struct sockaddr *) &client_addr, sizeof(client_addr)) < 0) {error(1, errno, "bind failed");}bzero(&server_addr, sizeof(server_addr));server_addr.sun_family = AF_LOCAL;strcpy(server_addr.sun_path, argv[1]);char send_line[MAXLINE];bzero(send_line, MAXLINE);char recv_line[MAXLINE];while (fgets(send_line, MAXLINE, stdin) != NULL) {int i = strlen(send_line);if (send_line[i - 1] == '\\n') {send_line[i - 1] = 0;}size_t nbytes = strlen(send_line);printf("now sending %s \\n", send_line);if (sendto(sockfd, send_line, nbytes, 0, (struct sockaddr *) &server_addr, sizeof(server_addr)) != nbytes)error(1, errno, "sendto error");int n = recvfrom(sockfd, recv_line, MAXLINE, 0, NULL, NULL);recv_line[n] = 0;fputs(recv_line, stdout);fputs("\\n", stdout);}exit(0);
}
下面这段代码就展示了 server 和 client 通过数据报应答的场景:我们可以看到, server 陆续收到 client 发送的数据报,同时, client 也收到了 server 的应答。效果如下:
./unixdataserver /tmp/unixdata.sock
Receive: g1
now sending: Hi, g1
Receive: g2
now sending: Hi, g2
Receive: g3
now sending: Hi, g3
$ ./unixdataclient /tmp/unixdata.sock
g1
now sending g1
Hi, g1
g2
now sending g2
Hi, g2
g3
now sending g3
Hi, g3
^C
5.3 k8s 和 docker 的 socket 案例
k8s 有很多优秀的设计:k8s 的 CRI(Container Runtime Interface),其思想是将 k8s 的主要逻辑和 Container Runtime 的实现解耦。
可通过 netstat 命令查看 Linux 系统内的本地套接字状况
- 下面这张图列出了路径为 /var/run/dockershim.socket 的 stream 类型的本地套接字,可以清楚地看到开启这个套接字的进程为 kubelet。kubelet 是 k8s 的一个组件,这个组件负责将控制器和调度器的命令转化为单机上的容器实例。为了实现和容器运行时的解耦,kubelet 设计了基于本地套接字的 client - server GRPC 调用。
- docker-containerd.sock 是 Docker 的套接字
NETSTAT(8) Linux Programmer's Manual NETSTAT(8)NAMEnetstat - Print network connections, routing tables, interface statistics, masquerade connections, and multicast memberships-a, --allShow both listening and non-listening sockets. With the --interfaces option, show interfaces that are not up--protocol=family , -ASpecifies the address families (perhaps better described as low level protocols) for which connections are to be shown. family is a comma (',') separated list of address family keywords like inet, unix, ipx, ax25, netrom, and ddp. This has the same effect as using the --inet, --unix (-x), --ipx, --ax25, --netrom, and --ddp options.The address family inet includes raw, udp and tcp protocol sockets.
在 /var/run 可看到 docker 的套接字如下:
如果不知道缺少的头文件,可以用 man 查询:
# 可以在linux系统里执行man命令,例如man socket:SOCKET(2) Linux Programmer's Manual SOCKET(2)NAMEsocket - create an endpoint for communicationSYNOPSIS#include <sys/types.h> /* See NOTES */#include <sys/socket.h>int socket(int domain, int type, int protocol);