一、TCP 客户机/服务器
1、TCP 协议的基本特征
TCP 提供客户机与服务器的连接
一个完整 TCP 通信过程需要依次经历三个阶段
首先,客户机必须建立与服务器的连接,所谓虚电路。
然后,凭借已建立好的连接,通信双方相互交换数据。
最后,客户机与服务器双双终止连接,结束通信过程。
TCP 保证数据传输的可靠性
TCP 的协议栈底层在向另一端发送数据时,会要求对方在一个给定的时间窗口内返回确认。如果超过了这个时间窗口仍没有收到确认,则 TCP 会重传数据并等待更长的时间。只有在数次重传均告失败以后,TCP 才会最终放弃。TCP 含有用于动态估算数据往返时间(Round-Trip Time, RTT)的算法,因此它知道等待一个确认需要多长时间。
TCP 保证数据传输的有序性
TCP 的协议栈底层在向另一端发送数据时,会为所发送数据的每个字节指定一个序列号。即使这些数据字节没有能够按照发送时的顺序到达接收方,接收方的 TCP 也可以根据它们的序列号重新排序,再把最后的结果交给应用程序。
如果 TCP 收到重复的数据(比如发送方认为数据已丢失并重传,但它可能并没有真的丢失,而只是由于网络拥塞而被延误),它也可以根据序列号做出判断,丢弃重复的数据。
TCP 提供流量控制
TCP 的协议栈底层在从另一端接收数据时,会不断告知对方它能够接收多少字节的数据,即所谓通告窗口。任何时候,这个窗口都反映了接收缓冲区可用空间的大小,从而确保不会因为发送方发送数据过快而导致接收缓冲区溢出。
TCP 是流式传输协议
TCP 是一个字节流协议,无记录边界
应用程序如果需要确定记录边界,必须自己实现
TCP 是全双工的
在给定的连接上,应用程序在任何时候都既可以发送数据也可以接受数据。因此,TCP 必须跟踪每个方向上数据流的状态信息,如序列号和通告窗口的大小。
2、TCP 连接的生命周期
(1)建立连接
被动打开
服务器必须首先做好准备随时接受来自客户机的连接请求。
三路握手
客户机的 TCP 协议栈服务器发送一个 SYN 分节,告知对方自己将在连接中发送数据的初始序列号,谓之主动打开。
服务器的 TCP 协议栈向客户机发送一个单个分节,其中不仅包括对客户机 SYN 分节的 ACK 应答,还包含服务器自己的 SYN 分节,以告知对方自己再同一连接中发送数据的初始序列号。
客户机的 TCP 协议栈向服务器返回 ACK 应答,以表示对服务器所发 SYN 的确认。
(2)交换数据
一旦连接建立,客户机即可构造请求并发往服务器。
服务器接收并处理来自客户机的请求包,构造响应包。
服务器向客户机发送响应包,同时捎带对客户机请求包的 ACK 应答。到哪如果服务器处理请求和构造响应的时间长于 200 毫秒,则应答也可能先于响应发出。
客户机接收来自服务器的响应包,同时向对方发送 ACK 应答。
(3)终止连接
客户机或者服务器主动关闭连接,TCP 协议栈向对方发送 FIN 分节,表示数据通信结束。如果此时尚有数据滞留于发送缓冲区中,则 FIN 分节跟在所有未发送数据之后。
接收到 FIN 分节的另一端执行被动关闭,一方面通过 TCP 协议栈向对方发送 ACK 应答,另一方面向应用程序传递文件结束符。如果此时接收缓冲区不空,则将所接收到的 FIN 分节追加到接收缓冲区的末尾。
一段时间以后,方才接收到 FIN 分节的进程关闭自己的连接,同时通过 TCP 协议栈向对方发送 FIN 分节。
对方在收到 FIN 分节后发送 ACK 应答。
3、常用函数
(1)函数 listen:启动侦听
在指定套接字上启动对连接请求的侦听
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int listen(int sockfd, int backlog);
返回值:成功返回 0,失败返回 -1
《1》参数解析
sockfd:套接字描述符
backlog:未决链接请求的最大值
《2》函数解析
socket 函数所创建的套接字一律被初始化为主动套接字,即可以通过后续 connect 函数调用向服务器发起连接请求的客户机套接字。listen 函数可以将一个这样的主动套接字转换为被动套接字,既可以等待并接受来自客户机的连接请求的服务器套接字。
被 listen 函数启动侦听的套接字将由 CLOSED 状态转入 LISETN 状态。
客户机调用 connect 函数即开启了 TCP 连接建立的第一路握手:通过协议栈向服务器发送 SYN 分节。服务器的 LISTEN 套接字一旦收到该分节,即创建一个新的处于 SYN_RCVD 装填的套接字,并将其排入未完成连接队列。
服务器的 TCP 协议栈不断监视未完成连接队列的状态,并在适当的时机依次处理其中等待连接的套接字。一旦某个
套接字上的第二、三路握手完成,由 SYN_RCVD 状态转入 ESTABLISTEN 状态,即被移送到已完成连接队列。
两个队列中的套接字个数之和不能超过 backlog 参数值。
若未完成连接队列和已完成连接队列中的套接字个数之和已经达到 backlog,此时又有客户机通过 connect 函数发起连接请求,则该请求所产生的 SYN 分节将被服务器的 TCP 协议栈直接忽略。客户机的 TCP 协议栈会因第一路握手应答超时而重发 SYN 分节,期望不久能在未决队列中找到空闲位置。若多次重发均告失败,则客户机放弃,connect 函数返回失败。
客户机对 connect 函数的调用在第二路握手完成时即返回,而此时服务器连接套接字可能还子啊未完成连接队列(第三路握手尚未完成)或已完成连接队列(套接字尚未返回给用户进程)中。这种情况下客户机发送的数据,会被服务器的 TCP 协议栈排队缓存,直到接收缓冲区满为止。
(2)函数 accept:等待连接
在指定套接字上等待并接受连接请求
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
返回值:成功返回连接套接字描述符,失败返回 -1
《1》参数解析
sockfd:侦听套接字描述符
addr:输出连接请求发起者地址结构
addrlen:输入/输出,连接请求发起者地址结构长度(以字节为单位)
《2》函数解析
accept 函数由 TCP 服务器调用,返回排在已完成连接队列首部的连接套接字对象的描述符,若队列为空则阻塞。
若 accept 函数执行成功,则通过 addr 和 addrlen 向调用者输出发起连接请求的客户机的协议地址及其字节长度。
注意 addrlen 既是输入参数也是输出参数。调用 accept 函数时,指针 addrlen 所指向的变量被初始化 addr 结构体的字节大小;等到该函数返回时,该指针的目标则被更新为系统内核保存在 addr 结构体内的实际字节数。
accept 函数成功返回的是一个有别于其参数套接字,由系统内核自动生成的全新套接字描述符。它代表与客户机的 TCP 连接,因此被称为连接套接字,而该函数的第一个参数则被称为侦听套接字。通常一个服务器只有一个侦听套接字,且一直存在直到服务器关闭,而连接套接字则是一个客户机一个,专门负责与该客户机的通信。
(3)函数 recv:接收数据
通过指定套接字接收数据
#include <sys/types.h>
#include <sys/socket.h>
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
返回值:成功返回实际接收到的字节数,失败返回 -1
《1》参数解析
sockfd:套接字描述符
buf:应用程序接收缓冲区
len:期望接收的字节数
flags:接收标志,一般取 0,还可取以下值:
MSG_DONTWAIT 以非阻塞方式接受数据
MSG_OOB 接收带外数据
MSG_PEEK 只查看可接收的数据,函数返回后数据依然留在接收缓冲区中
如前所述,客户机或者服务器主动关闭连接,TCP 协议栈向对方发送 FIN 分节,表示数据通信结束,接收到 FIN 分节的另一端执行被动关闭,一方面通过 TCP 协议栈向对方发送 ACK 应答,另一方面向应用程序传递文件结束符,此时 recv 函数返回 0.
《2》阻塞于非阻塞
套接字 I/O 的缺省方式都是阻塞的。对于 TCP 而言,如果接收缓冲区中没有数据,recv 函数将会阻塞,直到有数据到来并被复制到 buf 缓冲区时才会返回。此时所接收都的数据可能比 len 参数期望接收的字节数少。除非调用 recv 函数时使用 MSG_WAITALL 标志,不接收到 len 字节的数据,函数就不返回。但即便使用了 MSG_WAITALL 标志,实际接收到的字节数在以下三种情况下仍然可能比期望的少。
函数被信号中断
连接被对方终止
发生套接字错误
MAG_DONTWAIT 标志令接收过程以非阻塞方式进行,即便收不到数据,recv 函数也会立即返回,返回值 -1,errno 为 EAGAIN 或 EWOULDBLOCK
(4)函数 send:发送数据
通过指定套接字发送数据
#include <sys/types.h>
#include <sys/socket.h>
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
返回值:成功返回实际被发送的字节数,失败返回 -1
《1》参数解析
sockfd:套接字描述符
buf:应用程序发送缓冲区
len:期望发送的字节数
flags:发送标志,一般取 0,还可以取以下值:
MSG_DONTWAIT 以非阻塞方式发送数据
MSG_OOB 发送带外数据
MSG_DONTROUTE 不查路由器,直接在本地网络中寻找目的主机
《2》阻塞于非阻塞
套接字 I/O的缺省方式都是阻塞的。对于 TCP 而言,如果发送缓冲区中没有足够的空闲空间,send 函数将会阻塞,直到其空闲空间足以容纳 len 字节的待发送数据,并在将全部待发送数据复制到发送缓冲区后才会返回。
MSG_DONTWAIT 标志令发送过程以非阻塞方式进行,即便发送缓冲区中一个字节的空闲空间都没有,send 函数也会立即返回,返回值为 -1,errno 为 EAGAIN 或 EWOULDBLOCK。
在非阻塞方式下,如果发送缓冲区中尚有少量空闲空间,则会将部分待发送数据复制到发送缓冲区,同时返回复制到发送缓冲区中的字节数。
4、编程模型
基于 TCP 协议实现网络通信的编程模型
5、服务模型
迭代服务
服务器在单线程中以循环迭代的方式依次处理每个客户机的业务需求。迭代模型的前提是针对每个客户机的处理时间必须足够短暂,否则会延误对其客户机的响应。
并发服务
主进程阻塞在 accept 函数上。每当一个客户机与服务器建立连接,accept 函数返回,即通过 fork 函数创建子进程,主进程继续等待新的连接,子进程处理客户机业务。
首先服务器主进程阻塞于针对侦听套接字的 accept 调用,客户机进程通过 connect 函数向服务器发起连接请求
客户机的连接请求被系统内核接受,服务器主进程从 accept 函数中返回,同时得到可用于通信的连接套接字
服务器主进程调用 fork 函数创建子进程,子进程复制父进程的文件描述符,因此子进程也有侦听和连接两个套接字描述符
服务器主进程关闭连接套接字;服务器子进程关闭侦听套接字。主进程通过循环继续阻塞于针对侦听套接字的 accept 调用,而子进程则通过连接套接字与客户机通信
套接字描述符与普通的我那件描述符一样,是带有引用计数的。在一个套接字描述符上调用 close 函数,并不一定真的关闭该套接字,而只是将其引用计数减一。只有当套接字描述符的引用计数被减到零时,才真的会释放该套接字对象所占用的资源,并向对方发送 FIN 分节。因此服务器主进程关闭连接套接字,并不会影响子进程通过该套接字与客户机通信。同理,服务器子进程关闭侦听套接字也不会影响主进程通过套接字继续等待连接。
如果服务器主进程在创建子进程后不关闭连接套接字,一方面将耗尽其可用文件描述符;令一方面在子进程结束通信关闭链接套接字时,其描述符上的引用计数只会由 2 变成 1,而不会变成 0,TCP 协议栈将永远保持此连接。
6、示例说明
基于 TCP 协议的客户机与服务器
//服务器 tcpA.c
#include <stdio.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <arpa/inet.h>
int main()
{int listenfd = socket (AF_INET, SOCK_STREAM, 0);if (listenfd == -1){perror ("socket");exit (EXIT_FAILURE);}struct sockaddr_in addr;addr.sin_family = AF_INET;addr.sin_port = htons (8888);addr.sin_addr.s_addr = INADDR_ANY;if (bind (listenfd, (struct sockaddr*)&addr, sizeof (addr)) == -1){perror ("bind");exit (EXIT_FAILURE);}if (listen (listenfd, 1024) == -1){perror ("listen");exit (EXIT_FAILURE);}struct sockaddr_in addrcli = {};socklen_t addrlen = sizeof (addrcli);int connfd = accept (listenfd, (struct sockaddr*)&addrcli, &addrlen);if (connfd == -1){perror ("accept");exit (EXIT_FAILURE);}printf ("服务器已接受来自%s:%hu客户机的连接请求\n", inet_ntoa (addrcli.sin_addr),ntohs (addrcli.sin_port));char buf[1024];ssize_t rcvd = recv (connfd, buf, sizeof (buf), 0);if (rcvd == -1){perror ("recv");exit (EXIT_FAILURE);}if (rcvd == 0){printf ("客户机已关闭连接\n");exit (EXIT_FAILURE);}buf[rcvd] = '\0';printf ("客户端说:%s\n", buf);printf ("服务器说:");gets (buf);ssize_t sent = send (connfd, buf, strlen (buf) * sizeof (buf[0]), 0);if (sent == -1){perror ("send");exit (EXIT_FAILURE);}if (close (listenfd) == -1){perror ("close");exit (EXIT_FAILURE);}if (close (connfd) == -1){perror ("close");exit (EXIT_FAILURE);}return 0;
}
//客户端 tcpB.c
#include <stdio.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <arpa/inet.h>
int main()
{int listenfd = socket (AF_INET, SOCK_STREAM, 0);if (listenfd == -1){perror ("socket");exit (EXIT_FAILURE);}struct sockaddr_in addr;addr.sin_family = AF_INET;addr.sin_port = htons (8888);addr.sin_addr.s_addr = inet_addr("127.0.0.1");if (connect (listenfd, (struct sockaddr*)&addr, sizeof (addr)) == -1){perror ("connect");exit (EXIT_FAILURE);}char buf[1024] = "你好,服务器";printf ("客户端说:%s\n", buf);ssize_t sent = send (listenfd, buf, strlen (buf) * sizeof (buf[0]), 0);if (sent == -1){perror ("send");exit (EXIT_FAILURE);}ssize_t rcvd = recv (listenfd, buf, sizeof (buf), 0);if (rcvd == -1){perror ("recv");exit (EXIT_FAILURE);}buf[rcvd] = '\0';printf ("服务器说:%s\n", buf);if (close (listenfd) == -1){perror ("close");exit (EXIT_FAILURE);}return 0;
}
输出结果:
在一个终端执行:
# ./tcpA
服务器已接受来自127.0.0.1:41428客户机的连接请求
客户端说:你好,服务器
服务器说:hello另一个终端执行:
# ./tcpB
客户端说:你好,服务器
服务器说:hello
二、UDP 客户机/服务器
1、UDP 协议的基本特点
UDP 不提供客户机与服务器的连接
UDP 的客户机与服务器不必存在长期关系。一个 UDP 的客户机在通过一个套接字向一个 UDP 服务器发送了一个数据报之后,马上可以通过同一个套接字向另一个 UDP 服务器发送另一个数据报。同样,一个 UDP 服务器也可以通过同一个套接字接收来自不同客户机的数据报。
UDP 不保证数据传输的可靠性和有序性
UDP 的协议栈底层不提供诸如确认、超时重传、RTT估算以及序列号等机制。因此 UDP 数据报在网络传输的过程中,可能丢失,也可能重复,甚至重新排序。应用程序必须自己处理这些情况。
UDP 不提供流量控制
UDP 协议栈底层只是一味地按照发送方的速率发送数据,全然不顾接收方的缓冲区是否装得下。
UDP 是记录式传输协议
每个 UDP 数据报都有一定长度,一个数据报就是一条记录。如果数据报正确地到达了目的地,那么数据报的长度将被传递接收方的应用进程。
UDP 是全双工的
在一个 UDP 套接字上,应用程序在任何时候都既可以发送数据也可以接受数据。
2、常用函数
(1)函数 recvfrom:接收数据
从指定的地址结构接收数据
#include <sys/types.h>
#include <sys/socket.h>
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,struct sockaddr *src_addr, socklen_t *addrlen);
返回值:成功返回实际接收到的字节数,失败返回 -1
《1》参数解析
sockfd:套接字描述符
buf:应用程序接收缓冲区
len:期望接收的字节数
flag:接收标志,一般取 0,还可取以下值:
MSG_DONTWAIT 以非阻塞方式接收数据
MSG_OOB 接收带外数据
MSG_PEEK 只查看可接收的数据,函数返回后数据依然留在接收缓冲区中
MSG_WAITALL 等待所有数据,即不接收到 len 字节的数据,函数就不返回
src_addr:输出数据报发送者的地址结构,可置为 NULL
addrlen:输出 src_addr 参数所指向内存块的字节数,输出数据发送者地址结构的字节数,可置为 NULL
《2》函数解析
recvfrom 函数返回 0,表示接收到一个空数据报(只有 IP 和 UDP 包头而无数据内容),与对方是否关闭套接字无关。
(2)函数 sendto:发送数据
向指定的地址结构发送数据
#include <sys/types.h>
#include <sys/socket.h>
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,const struct sockaddr *dest_addr, socklen_t addrlen);
返回值:成功返回实际被发送的字节数,失败返回 -1
《1》参数解析
sockfd:套接字描述符
buf:应用程序发送缓冲区
len:期望发送的字节数
flags:发送标志,一般取 0,还可取以下值:
MSG_DONTWAIT 以非阻塞方式发送数据
MSG_OOB 发送带外数据
MSG_DONTROUTE 不查路由表,直接在本地网络中寻找目的主机
dest_addr:数据报接收者的地址结构
addrlen:数据报接收者地址结构的字节数
3、编程模型
基于 UDP 协议的无连接编程模型
UDP 服务器的阻塞焦点不在 accept 函数上,而在 recvfrom 函数上。任何一个 UDP 客户机通过 sendto 函数发送的请求数据都可以被 recvfrom 函数返回给 UDP 服务器,其输出的客户机地址结构 src_addr 可直接被用于向客户机返回响应时调用 sendto 函数的输入 dest_addr
基于 UDP 协议的有连接编程模型
UDP 的 connect 函数与 TCP 的 connect 函数完全不同,既无三路握手,亦无虚拟电路,而仅仅是将传递给该函数的对方地址结构缓存在套接字对象中。此后收发数据时,可不使用 recvfrom/sendto 函数,而是使用 recv/send 或者 read/write 函数,直接和所连接的对方主机通信。
3、服务模型
迭代服务
基于 UDP 协议建立通信的客户机和服务器,不需要维持长期的连接。因此 UDP 服务器在一个单线程中,以循环迭代的方式即可处理来自不同客户机的业务需求。
4、示例说明
基于 UDP 协议的客户机和服务器
//服务器 udpA.c
#include <stdio.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <arpa/inet.h>
int main()
{int listenfd = socket (AF_INET, SOCK_DGRAM, 0);if (listenfd == -1){perror ("socket");exit (EXIT_FAILURE);}struct sockaddr_in addr;addr.sin_family = AF_INET;addr.sin_port = htons (8888);addr.sin_addr.s_addr = INADDR_ANY;if (bind (listenfd, (struct sockaddr*)&addr, sizeof (addr)) == -1){perror ("bind");exit (EXIT_FAILURE);}char buf[1024];struct sockaddr_in addrcli = {};socklen_t addrlen = sizeof (addrcli);ssize_t rcvd = recvfrom (listenfd, buf, sizeof (buf), 0, (struct sockaddr*)&addrcli, &addrlen);if (rcvd == -1){perror ("recvfrom");exit (EXIT_FAILURE);}buf[rcvd] = '\0';printf ("客户端说:%s\n", buf);printf ("服务器说:");gets (buf);ssize_t sent = sendto (listenfd, buf, strlen (buf) * sizeof (buf[0]), 0, (struct sockaddr*)&addrcli, sizeof (addrcli));if (sent == -1){perror ("send");exit (EXIT_FAILURE);}if (close (listenfd) == -1){perror ("close");exit (EXIT_FAILURE);}return 0;
}
//客户端 udpB.c
#include <stdio.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <arpa/inet.h>
int main()
{int listenfd = socket (AF_INET, SOCK_DGRAM, 0);if (listenfd == -1){perror ("socket");exit (EXIT_FAILURE);}struct sockaddr_in addr;addr.sin_family = AF_INET;addr.sin_port = htons (8888);addr.sin_addr.s_addr = inet_addr("127.0.0.1");char buf[1024] = "你好,服务器";printf ("客户端说:%s\n", buf);ssize_t sent = sendto (listenfd, buf, strlen (buf) * sizeof (buf[0]), 0, (struct sockaddr*)&addr, sizeof (addr));if (sent == -1){perror ("send");exit (EXIT_FAILURE);}struct sockaddr_in addrser = {};socklen_t addrlen = sizeof (addrser);ssize_t rcvd = recvfrom (listenfd, buf, sizeof (buf), 0, (struct sockaddr*)&addrser, &addrlen);if (rcvd == -1){perror ("recvfrom");exit (EXIT_FAILURE);}buf[rcvd] = '\0';printf ("服务器说:%s\n", buf);if (close (listenfd) == -1){perror ("close");exit (EXIT_FAILURE);}return 0;
}
输出结果:
在一个终端执行:
# ./udpA
客户端说:你好,服务器
服务器说:hello另一个终端执行:
# ./udpB
客户端说:你好,服务器
服务器说:hello