TCP 是一种流式协议
在前面的章节中,我们讲的都是单个客户端 - 服务器的例子,可能会给你造成一种错觉,好像 TCP 是一种应答形式的数据传输过程,比如发送端一次发送 network 和 program 这样的报文,在前面的例子中,我们看到的结果基本是这样的:
发送端:network ----> 接收端回应:Hi, network
发送端:program -----> 接收端回应:Hi, program
这其实是一个假象,之所以会这样,是因为网络条件比较好,而且发送的数据也比较少。
为了让大家理解 TCP 数据是流式的这个特性,我们分别从发送端和接收端来阐述。
在发送端,当我们调用 send 函数完成数据“发送”以后,数据并没有被真正从网络上发送出去,只是从应用程序拷贝到了内核协议栈中,至于什么时候真正被发送,取决于发送窗口、拥塞窗口以及当前发送缓冲区的大小等条件。也就是说,我们不能假设每次 send 调用发送的数据,都会作为一个整体完整地被发送出去。
如果我们考虑实际网络传输过程中的各种影响,假设发送端陆续调用 send 函数先后发送 network 和 program 报文,那么实际的发送很有可能是这个样子的。
第一种情况,一次性将 network 和 program 在一个 TCP 分组中发送出去,像这样:
...xxxnetworkprogramxxx...
第二种情况,program 的部分随 network 在一个 TCP 分组中发送出去,像这样:
TCP 分组 1:
...xxxxxnetworkpro
TCP 分组 2:
gramxxxxxxxxxx...
第三种情况,network 的一部分随 TCP 分组被发送出去,另一部分和 program 一起随另一个 TCP 分组发送出去,像这样。
TCP 分组 1:
...xxxxxxxxxxxnet
TCP 分组 2:
workprogramxxx...
实际上类似的组合可以枚举出无数种。不管是哪一种,核心的问题就是,我们不知道 network 和 program 这两个报文是如何进行 TCP 分组传输的。换言之,我们在发送数据时,不应该假设“数据流和 TCP 分组是一种映射关系”。就好像在前面,我们似乎觉得 network 这个报文一定对应一个 TCP 分组,这是完全不正确的。
如果我们再来看客户端,数据流的特征更明显。
接收端缓冲区保留了没有被取走的数据,随着应用程序不断从接收端缓冲区读出数据,接收端缓冲区就可以容纳更多新的数据。如果我们使用 recv 从接收端缓冲区读取数据,发送端缓冲区的数据是以字节流的方式存在的,无论发送端如何构造 TCP 分组,接收端最终受到的字节流总是像下面这样:
xxxxxxxxxxxxxxxxxnetworkprogramxxxxxxxxxxxx
关于接收端字节流,有两点需要注意:
-
netwrok 和 program 的顺序肯定是会保持的,也就是说,先调用 send 函数发送的字节,总在后调用 send 函数发送字节的前面,这个是由 TCP 严格保证的;
-
如果发送过程中有 TCP 分组丢失,但是其后续分组陆续到达,那么 TCP 协议栈会缓存后续分组,直到前面丢失的分组到达,最终,形成可以被应用程序读取的数据流。
网络字节排序
计算机最终保存和传输的都是 0101 这样的二进制数据,字节流在网络上的传输,也是通过二进制来完成的。
从二进制到字节是通过编码完成的,比如著名的 ASCII 编码,通过一个字节 8 个比特对常用的西方字母进行了编码。
这里有一个有趣的问题,如果需要传输数字,比如 0x0201,对应的二进制为 00000010000000001,那么两个字节的数据到底是先传 0x01,还是相反?
不同的系统会有两种存法,一种是将 0x02 高字节存放在起始地址,这个叫做大端字节序。另一种相反,将 0x01 低字节存放在起始地址,这个叫做小端字节序。
但在网络传输中,必须保证双方都用同一种标准来表达。这个标准就涉及到了网络字节序的选择问题,对于网络字节序,必须二选一。我们可以看到网络协议使用的是大端字节序,我个人觉得大端字节序比较符合人类的思维习惯,你可以想象手写一个多位数字,从开始往小位写,自然会先写大位,比如写 12, 1234,这个样子。
为了保证网络字节序一致,POSIX 标准提供了如下的转换函数:
uint16_t htons (uint16_t hostshort)
uint16_t ntohs (uint16_t netshort)
uint32_t htonl (uint32_t hostlong)
uint32_t ntohl (uint32_t netlong)
这里函数中的 n 代表的就是 network,h 代表的是 host,s 表示的是 short,l 表示的是 long,分别表示 16 位和 32 位的整数。
这些函数可以帮助我们在主机(host)和网络(network)的格式间灵活转换。当使用这些函数时,我们并不需要关心主机到底是什么样的字节顺序,只要使用函数给定值进行网络字节序和主机字节序的转换就可以了。
你可以想象,如果碰巧我们的系统本身是大端字节序,和网络字节序一样,那么使用上述所有的函数进行转换的时候,结果都仅仅是一个空实现,直接返回。
比如这样:
# if __BYTE_ORDER == __BIG_ENDIAN
/* The host byte order is the same as network byte order,so these functions are all just identity. */
# define ntohl(x) (x)
# define ntohs(x) (x)
# define htonl(x) (x)
# define htons(x) (x)
报文读取和解析
报文是以字节流的形式呈现给应用程序的,那么随之而来的一个问题就是,应用程序如何解读字节流呢?
这就要说到报文格式和解析了。报文格式实际上定义了字节的组织形式,发送端和接收端都按照统一的报文格式进行数据传输和解析,这样就可以保证彼此能够完成交流。
报文格式最重要的是如何确定报文的边界。常见的报文格式有两种方法,一种是发送端把要发送的报文长度预先通过报文告知给接收端;另一种是通过一些特殊的字符来进行边界的划分。
显式编码报文长度
报文格式
下面我们来看一个例子,这个例子是把要发送的报文长度预先通过报文告知接收端
这个报文的格式很简单,首先 4 个字节大小的消息长度,其目的是将真正发送的字节流的大小显式通过报文告知接收端,接下来是 4 个字节大小的消息类型,而真正需要发送的数据则紧随其后。
发送报文
发送端的程序如下:
#include "common.h"
int main(int argc, char **argv) {
if (argc != 2) {
error(1, 0, "usage: tcpclient <IPaddress>");
}
int socket_fd;
socket_fd = socket(AF_INET, SOCK_STREAM, 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);
int connect_rt = connect(socket_fd, (struct sockaddr *) &server_addr, server_len);
if (connect_rt < 0) {
error(1, errno, "connect failed ");
}
struct {
u_int32_t message_length;
u_int32_t message_type;
char data[128];
} message; // 图示的报文格式转化为结构体
int n;
//从标准输入读入数据,分别对消息长度、类型进行了初始化,这里使用 htonl 函数将字节大小转化为了网络字节顺序
while (fgets(message.data, sizeof(message.data), stdin) != NULL) {
n = strlen(message.data);
message.message_length = htonl(n);
message.message_type = 1;
if (send(socket_fd, (char *) &message, sizeof(message.message_length) + sizeof(message.message_type) + n, 0) <
0)
error(1, errno, "send failure");
}
exit(0);
}
解析报文:程序
现在common.h中加入后面所需的函数
size_t read_message(int fd, char *buffer, size_t length) {
u_int32_t msg_length;
u_int32_t msg_type;
int rc;
// 调用 readn 函数获取 4 个字节的消息长度数据
rc = readn(fd, (char *) &msg_length, sizeof(u_int32_t));
if (rc != sizeof(u_int32_t))
return rc < 0 ? -1 : 0;
msg_length = ntohl(msg_length);
//调用 readn 函数获取 4 个字节的消息类型数据
rc = readn(fd, (char *) &msg_type, sizeof(msg_type));
if (rc != sizeof(u_int32_t))
return rc < 0 ? -1 : 0;
/* 判断buffer是否可以容纳下数据 */
if (msg_length > length) {
return -1;
}
rc = readn(fd, buffer, msg_length);
if (rc != msg_length)
return rc < 0 ? -1 : 0;
return rc;
}
下面是服务器的程序,和客户端不一样的是,服务器端需要对报文进行解析。
#include "common.h"
static int count;
static void sig_int(int signo) {
printf("\nreceived %d datagrams\n", count);
exit(0);
}
int main(int argc, char **argv) {
int listenfd;
listenfd = socket(AF_INET, SOCK_STREAM, 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);
int on = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
int rt1 = bind(listenfd, (struct sockaddr *) &server_addr, sizeof(server_addr));
if (rt1 < 0) {
error(1, errno, "bind failed ");
}
int rt2 = listen(listenfd, LISTENQ);
if (rt2 < 0) {
error(1, errno, "listen failed ");
}
signal(SIGPIPE, SIG_IGN);
int connfd;
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
if ((connfd = accept(listenfd, (struct sockaddr *) &client_addr, &client_len)) < 0) {
error(1, errno, "bind failed ");
}
char buf[128];
count = 0;
// 循环处理字节流,调用 read_message 函数进行报文解析工作,并把报文的主体通过标准输出打印出来。
while (1) {
int n = read_message(connfd, buf, sizeof(buf));
if (n < 0) {
error(1, errno, "error read message");
} else if (n == 0) {
error(1, 0, "client closed \n");
}
buf[n] = 0;
printf("received %d bytes: %s\n", n, buf);
count++;
}
exit(0);
}
实验
依次启动服务器和客户端。
$./streamserver
received 8 bytes: network
received 5 bytes: good
$./streamclient
network
good
特殊字符作为边界
另外一种报文格式就是通过设置特殊字符作为报文边界。HTTP 是一个非常好的例子。
HTTP 通过设置回车符、换行符做为 HTTP 报文协议的边界。
下面的 read_line 函数就是在尝试读取一行数据,也就是读到回车符\r,或者读到回车换行符\r\n为止。
int read_line(int fd, char *buf, int size) {
int i = 0;
char c = '\0';
int n;
while ((i < size - 1) && (c != '\n')) {
n = recv(fd, &c, 1, 0);
if (n > 0) {
if (c == '\r') { //如果读到了回车符`\r`
n = recv(fd, &c, 1, MSG_PEEK);//`MSG_PEEK`告诉OS在接收到数据时不要将其从缓冲区中删除,而是只是查看该数据而不进行实际读取。
if ((n > 0) && (c == '\n'))//看有没有换行符,如果有就读取
recv(fd, &c, 1, 0);
else
c = '\n';
}
buf[i] = c; // 填入数据
i++; // 移动指针
} else
c = '\n';
}
buf[i] = '\0';//最后封尾
return (i);
}