共计 5144 个字符,预计需要花费 13 分钟才能阅读完成。
欢迎大家前往腾讯云 + 社区,获取更多腾讯海量技术实践干货哦~
本文由 jackieluo 发表于云 + 社区专栏
TCP 客户端 - 服务器典型事件
下图是 TCP 客户端与服务器之间交互的一系列典型事件时间表:
首先启动服务器,等待客户端连接
启动客户端,连接到服务器
客户端发送一个请求给服务器,服务器处理请求,响应客户端
循环步骤 3
客户端给服务器发一个文件结束符,关闭客户端连接
服务器也关闭连接
基本 TCP 客户 - 服务器程序的套接口函数
套接口编程基本函数
socket 函数
为了执行网络 I /O,一个进程(无论是服务端还是客户端)必须做的第一件事情就是调用 socket 函数。
#include <sys/socket.h> /* basic socket definitions */
int socket(int family, int type, int protocol);/* 返回:非负描述字——成功,-1——出错 */
family——协议族
族
解释
AF_INET
IPv4 协议
AF_INET6
IPv6 协议
AF_LOCAL
Unix 域协议
AF_ROUTE
路由套接口
AF_KEY
密钥套接口
type——套接口类型
类型
解释
SOCK_STREAM
字节流套接口
SOCK_DGRAM
数据报套接口
SOCK_RAW
原始套接口
下面是有效的 family 和 type 组合(简略版):
AF_INET
AF_INET6
SOCK_STREAM
TCP
TCP
SOCK_DGRAM
UDP
UDP
SOCK_RAW
IPv4
IPv6
socket 函数返回一个套接口描述字,简称套接字(sockfd)。获取套接字无需指定地址,只需要指定协议族和套接口类型(如上表中的组合)。
connect 函数
TCP 客户用 connect 函数来建立一个与 TCP 服务器的连接。
#include <sys/socket.h> /* basic socket definitions */
int connect(int sockfd, const struct sockaddr * servaddr, socklen_t addrlen);/* 返回:0——成功,-1——出错 */
参数 sockfd 便是 socket 函数返回的套接口描述字。
套接口地址结构 servaddr 必须包含服务器的 IP 地址和端口号。
客户端不必非要绑定一个端口(调用 bind 函数),内核会选择源 IP 和一个临时端口。
connect 函数会触发 TCP 三次握手。有可能出现下面的错误情况:
1. 客户端未收到 SYN 分节的响应
第一次发出未收到,间隔 6s 再发一次,再没收到,隔 24 秒再发一次,总共等待 75s 还没收到则返回错误(ETIMEDOUT)。可以用时间日期程序验证一下:
查看本地网络信息:
JACKIELUO-MC0:intro jackieluo$ ifconfig
en0: flags=8863<UP,BROADCAST,SMART,RUNNING,SIMPLEX,MULTICAST> mtu 1500
ether f4:0f:24:2a:72:a6
inet6 fe80::1830:dbd:1b29:2989%en0 prefixlen 64 secured scopeid 0x6
inet 192.168.0.101 netmask 0xffffff00 broadcast 192.168.0.255
nd6 options=201<PERFORMNUD,DAD>
media: autoselect
status: active
将程序指向本地地址 192.168.0.101(确保时间日期服务器程序已运行),成功:
JACKIELUO-MC0:intro jackieluo$ ./daytimetcpcli 192.168.0.101
Sat Oct 6 17:06:55 2018
将程序指向本地子网地址 192.168.0.102,其主机 ID(102)不存在,等待几分钟后超时返回:
JACKIELUO-MC0:intro jackieluo$ ./daytimetcpcli 192.168.0.102
connect error: Operation timed out
2. 收到 RST
即服务器主机在指定端口上没有等待连接的进程,这称为“hard error”,客户端一接收到 RST,马上返回错误(ECONNREFUSED)。验证:
关闭之前本机运行的 daytimetcpsrv 进程
将程序指向本地地址 192.168.0.101:
JACKIELUO-MC0:intro jackieluo$ ./daytimetcpcli 192.168.0.101
connect error: Connection refused
3. 发出的 SYN 在路由器上引发了目的不可达 ICMP 错误
这个错误被称为“soft error”,最终返回 EHOSTUNREACH 或者 ENETUNREACH。
bind 函数
函数 bind 为套接口分配一个本地协议地址,包括 IP 地址和端口号。
#include <sys/socket.h> /* basic socket definitions */
int bind(int sockfd, const struct sockaddr * servaddr, socklen_t addrlen);/* 返回:0——成功,-1——出错 */
客户端可以不调用这个函数,由内核选择一个本地 ip 的临时端口就好。
服务器一般都会调用 bind 函数绑定 ip 地址和端口,供客户端调用。一个例外是 RPC(远程过程调用)服务器,它由内核为其选择临时端口。然后通过 RPC 端口映射器进行注册,客户端与该服务器连接之前,先通过端口映射器获取服务器的端口。
进程可以把一个特定的 IP 地址捆绑到它的套接口上。对于客户端,它发送的请求,源 IP 地址就是这个地址;对于服务器,如果绑定了 IP 地址,则只接受目的地为此 IP 地址的客户连接。
如果服务器不把 IP 地址绑定到套接口上,那么内核把客户端发送 SYN 所在分组的目的 IP 地址作为服务器的源 IP 地址。(即服务器收到 SYN 的 IP)
给函数 bind 指定用于捆绑的 IP 地址和 / 或端口号的结果:
IP 地址
端口
结果
0
内核选择 IP 地址和端口
非 0
内核选择 IP 地址,进程指定端口
本地 IP 地址
0
进程选择 IP 地址,内核指定端口
本地 IP 地址
非 0
进程选择 IP 地址和端口
listen 函数
函数 listen 仅被 TCP 服务器调用。
#include <sys/socket.h> /* basic socket definitions */
int listen(int sockfd, int backlog);/* 返回:0——成功,-1——出错 */
调用函数 socket 函数创建的套接口,默认是主动方,下一步应是调用 connect,CLOSED 的下一个状态是 SYN_SENT(见 TCP 状态转换图)。而函数 listen 将套接口转换成被动方,告诉内核,应接受指向此套接口的连接请求,CLOSED 状态变成 LISTEN。
函数 listen 的第二个参数 backlog 表示内核为此套接口排队的最大连接数。对于给定的监听套接口,内核会维护两个队列:
未完成连接队列(incomplete connection queue)SYN 分节已由客户发出,到达服务器,正在进行 TCP 的三路握手。此时这些套接口处于 SYN_RCVD 状态。
已完成连接队列(completed connection queue)SYN 分节已由客户发出,到达服务器,并且已完成三路握手。此时这些套接口处于 ESTABLISHED 状态。
当来自客户的 SYN 到达时,TCP 在未完成连接队列中创建一个新条目,直到三路握手中,第三个分节(客户对服务 SYN 的 ACK)到达,这个条目移到已完成连接队列的队尾。
当进程调用 accept 函数时,已完成连接队列的头部条目返回给进程。
两个队列之和不能超过 backlog
当一个客户 SYN 到达时,若这两个队列都是满的,TCP 就忽略此分节,且不发送 RST。客户 TCP 将重发 SYN,期望不久就能在队列中找到空闲位置。TCP 为监听套接口维护的两个队列
accept 函数
函数 accept 由 TCP 服务器调用,从已完成连接队列头部返回下一个已完成连接,若该队列为空,则进程睡眠(假定套接口为默认的阻塞方式)。
#include <sys/socket.h> /* basic socket definitions */
int accept(int sockfd, struct sockaddr *cliaddr, socklen_t *addrlen);/* 返回:非负描述字——成功,-1——出错 */
函数 accept 的第一个参数和返回值都是套接口描述字。其中,
第一个参数,称为监听套接口描述字,即由函数 socket 返回,也用于 bind,listen 的第一个参数。
返回值,称为已连接套接口描述字。
通常一个服务器,只生成一个监听套接口描述字,直到其关闭。而内核为每个被接受的客户连接,创建一个已连接套接口,当客户连接完成时,关闭该已连接套接口。
注意到 intro/daytimetcpsrv.c 中,后两个参数传的都是空指针,这是因为我们不关注客户的身份,无需知道客户的协议地址。
connfd = Accept(listenfd, (SA *) NULL, NULL);
稍作修改,不再传入空指针,见 intro/daytimetcpsrv1.c:
socklen_t len;
struct sockaddr_in servaddr, cliaddr;
…
connfd = Accept(listenfd, (SA *) &cliaddr, &len);
printf(“connection from %s, port %d\n”,
Inet_ntop(AF_INET, &cliaddr.sin_addr, buff, sizeof(buff)),
ntohs(cliaddr.sin_port));
kill 掉之前的 daytimetcpsrv 进程:
$ sudo lsof -i -P | grep -i “listen”
daytimetc 80986 root 3u IPv4 0xae12d925e4528793 0t0 TCP *:13 (LISTEN)
$ sudo kill -9 80986
编译运行新的服务端程序:
$ make daytimetcpsrv1.c daytimetcpsrv1
$ ./daytimetcpsrv1
重复执行客户端程序,发几个请求:
$ ./daytimetcpcli 127.0.0.1
Wed Sep 26 14:11:20 2018
$ ./daytimetcpcli 127.0.0.1
Wed Sep 26 14:17:06 2018
查看服务端打印:
connection from 127.0.0.1, port 58201
connection from 127.0.0.1, port 58342
注意到,由于客户端程序没有调用 bind 函数,内核为它的协议地址选择了源 ip 作为 IP 地址,临时端口号也发生了变化。
fork 和 exec 函数
#include <unistd.h>
pid_t fork(void);/* 返回:在子进程中为 0,在父进程中为子进程 ID,-1——出错 */
fork 函数调用一次,却返回两次。
在调用它的进程(即父进程),它返回一次,返回值是派生出来的子进程的进程 ID。父进程可能有很多子进程,必须通过返回值跟踪记录子进程 ID。
在子进程,它还返回一次,返回值为 0。子进程只有一个父进程,总可以通过 getppid 来得到父进程的 ID
通过返回值可以判断当前进程是子进程还是父进程。
父进程在调用 fork 之前打开的所有描述字在函数 fork 返回后都是共享的。网络服务器会利用这一特性:
父进程调用 accept。
父进程调用 fork,已连接套接口就在父进程与子进程间共享。(一般来说就是子进程读、写已连接套接口,而父进程关闭已连接套接口)。
fork 有两个典型应用:
一个进程为自己派生一个拷贝,并发执行任务,这也是典型的并发网络服务器模型。
一个进程想执行其他的程序,于是调用 fork 生成一个拷贝,利用子进程调用 exec 来执行新的程序。典型应用是 shell。
以文件形式存储在硬盘上的可执行程序若要被执行,需要由一个现有进程调用 exec 函数。我们将调用 exec 的进程称为调用进程,新程序的进程 ID 并不改变,仍处于当前进程。
小结
客户和服务器,从调用 socket 开始,返回一个套接口描述字。客户调用 connect,服务器调用 bind、listen、accept。最后套接口由 close 关闭。
多数 TCP 服务器是调用 fork 来实现并发处理多客户请求的。多数 UDP 服务器则是迭代的。
相关阅读系统重启后 nginx reload 不生效原因分析 SRS 开源直播服务 – StateThreads 微线程框架学习高性能网络编程 3 —-TCP 消息的接收【每日课程推荐】机器学习实战!快速入门在线广告业务及 CTR 相应知识