1、使用套接字进行读写:开始交流吧
这节主要讲 如何使用创建的套接字收发数据。
1.1、发送数据
发送数据时常用的有三个函数,分别是 write、send 和 sendmsg。
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 协议的紧急数据,用于客户端 - 服务器在特定场景下的紧急处理。)
第三个函数: 指定多重缓冲区传输数据,以结构体 msghdr 的方式发送数据。
问题:
既然套接字描述符是一种特殊的描述符,那么在套接字描述符上调用 write 函数,应该和在普通文件描述符上调用 write 函数的行为是一致的,都是通过描述符句柄写入指定的数据。(乍一看,两者的表现形式是一样,内在的区别还是很不一样的。)
对于普通文件描述符:
一个文件描述符代表了打开的一个文件句柄,通过调用 write 函数,操作系统内核帮我们不断地往文件系统中写入字节流。注意,写入的字节流大小通常和输入参数 size 的值是相同的,否则表示出错。
对于套接字描述符:
代表了一个双向连接,在套接字描述符上调用 write 写入的字节数有可能比请求的数量少,这在普通文件描述符情况下是不正常的。
原因: 操作系统内核为读取和发送数据做了很多我们表面上看不到的工作。
接下来拿 write 函数举例,重点阐述发送缓冲区的概念。
1.2、发送缓冲区
当 TCP 三次握手成功,TCP 连接成功建立后,操作系统内核会为每一个连接创建配套的基础设施,比如发送缓冲区。
发送缓冲区的大小可以通过套接字选项来改变;
当我们的应用程序调用 write 函数时,实际所做的事情是把数据从应用程序中拷贝到操作系统内核的发送缓冲区中,并不一定是把数据通过套接字写出去。
有几种情况:
-
第一种情况,操作系统内核的发送缓冲区足够大,可以直接容纳这份数据,那么皆大欢喜,我们的程序从 write 调用中退出,返回写入的字节数就是应用程序的数据大小。
-
第二种情况,操作系统内核的发送缓冲区是够大了,不过还有数据没有发送完,或者数据发送完了,但是操作系统内核的发送缓冲区不足以容纳应用程序数据(这种情况结果是什么?报错?还是直接返回?)
操作系统内核并不会返回,也不会报错,而是应用程序被阻塞,也就是说应用程序在 write 函数调用处停留,不直接返回。
那么什么时候才会返回呢?
大部分 UNIX 系统的做法是一直等到可以把应用程序数据完全放到操作系统内核的发送缓冲区中,再从系统调用中返回。 怎么理解呢?
- 操作系统内核是很聪明的,当 TCP 连接建立之后,它就开始运作起来。你可以把发送缓冲区想象成一条包裹流水线,有个聪明且忙碌的工人不断地从流水线上取出包裹(数据);
- 这个工人会按照 TCP/IP 的语义,将取出的包裹(数据)封装成 TCP 的 MSS 包,以及 IP 的 MTU 包;
- 这样我们的发送缓冲区就又空了一部分,于是又可以继续从应用程序搬一部分数据到发送缓冲区里,这样一直进行下去,到某一个时刻,应用程序的数据可以完全放置到发送缓冲区里。在这个时候,write 阻塞调用返回。
![img](https://static001.geekbang.org/resource/image/fd/dc/fdcdc766c6a6ebb7fbf15bb2d1e58bdc.png)
1.3、读取数据
套接字描述符本身和本地文件描述符并无区别,在 UNIX 的世界里万物都是文件;
意味着可以将 套接字描述符 传递给那些 原先为 处理本地文件 而设计的函数。这些函数包括 read 和 write 交换数据的函数。
1.4、read 函数
ssize_t read (int socketfd, void *buffer, size_t size)
read 函数:要求操作系统内核从套接字描述字 socketfd读取最多多少个字节(size),并将结果存储到 buffer 中。
返回值:
- 实际读取的字节数目;
- 如果返回值为 0,表示 EOF(end-of-file),这在网络中表示对端发送了 FIN 包,要处理断连的情况;
- 返回值为 -1,表示出错
如果是非阻塞 I/O,情况会略有不同,在后面的提高篇中我们会重点讲述非阻塞 I/O 的特点。
注意:这里是最多读取 size 个字节。如果我们想让应用程序每次都读到 size 个字节,就需要编写下面的函数,不断地循环读取。
/* 从socketfd描述字中读取"size"个字节. */
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 */
else
return (-1);
} else if (result == 0)
break; /* EOF(End of File)表示套接字关闭 */
length -= result;
buffer_pointer += result;
}
return (size - length); /* 返回的是实际读取的字节数*/
}
程序解释:
- 6-19 行的循环条件表示的是,在没读满 size 个字节之前,一直都要循环下去;
- 10-11 行表示的是非阻塞 I/O 的情况下,没有数据可以读,需要继续调用 read;
- 14-15 行表示读到对方发出的 FIN 包,表现形式是 EOF,此时需要关闭套接字;
- 17-18 行,需要读取的字符数减少,缓存指针往下移动;
- 20 行是在读取 EOF 跳出循环后,返回实际读取的字符数。
1.5、缓冲区实验
用一个客户端 - 服务器的例子来解释一下读取缓冲区和发送缓冲区的概念。
在这个例子中 客户端不断地发送数据,服务器端每读取一段数据之后进行休眠,以模拟实际业务处理所需要的时间。
服务器端读取数据的程序:
#include "lib/common.h"
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到本地地址,端口为12345 */
bind(listenfd, (struct sockaddr *) &servaddr, sizeof(servaddr));
/* listen的backlog为1024 */
listen(listenfd, 1024);
/* 循环处理用户请求 */
for (;;) {
clilen = sizeof(cliaddr);
connfd = accept(listenfd, (struct sockaddr *) &cliaddr, &clilen);
read_data(connfd); /* 读取数据 */
close(connfd); /* 关闭连接套接字,注意不是监听套接字*/
}
}
服务器程序解释:
- 21-35 行先后创建了 socket 套接字,bind 到对应地址和端口,并开始调用 listen 接口监听;
- 38-42 行循环等待连接,通过 accept 获取实际的连接,并开始读取数据;
- 8-15 行实际每次读取 1K 数据,之后休眠 1 秒,用来模拟服务器端处理时延。
客户端发送数据程序:
#include "lib/common.h"
#define MESSAGE_SIZE 102400
void 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);
int connect_rt = connect(sockfd, (struct sockaddr *) &servaddr, sizeof(servaddr));
if (connect_rt < 0) {
error(1, errno, "connect failed ");
}
send_data(sockfd);
exit(0);
}
- 31-37 行先后创建了 socket 套接字,调用 connect 向对应服务器端发起连接请求;
- 43 行在连接建立成功后,调用 send_data 发送数据;
- 6-11 行初始化了一个长度为 MESSAGE_SIZE 的字符串流;
- 16-25 行调用 send 函数将 MESSAGE_SIZE 长度的字符串流发送出去
1.6、实验一: 观察客户端数据发送行为
客户端程序发送了一个很大的字节流,程序运行起来之后,我们会看到服务端不断地在屏幕上打印出读取字节流的过程:
![img](https://static001.geekbang.org/resource/image/34/1d/3455bb84f5ee020bc14bc1e15ead4d1d.jpg)
而客户端直到最后所有的字节流发送完毕才打印出下面的一句话,说明在此之前 send 函数一直都是阻塞的,也就是说阻塞式套接字最终发送返回的实际写入字节数和请求字节数是相等的。
![img](https://static001.geekbang.org/resource/image/b5/e6/b56f01f842b2344e1480ff519d1627e6.jpg)
1.7、实验二: 服务端处理变慢
如果我们把服务端的休眠时间稍微调大,把客户端发送的字节数从 10240000 调整为 1024000,再次运行刚才的例子,我们会发现,客户端很快打印出一句话:
![img](https://static001.geekbang.org/resource/image/b5/e6/b56f01f842b2344e1480ff519d1627e6.jpg)
但与此同时,服务端读取程序还在屏幕上不断打印读取数据的进度,显示出服务端读取程序还在辛苦地从缓冲区中读取数据。
重点:
- 发送成功仅仅表示的是数据被拷贝到了发送缓冲区中,并不意味着连接对端已经收到所有的数据。
- 至于什么时候发送到对端的接收缓冲区,或者更进一步说,什么时候被对方应用程序缓冲所接收,对我们而言完全都是透明的。
总结
重点讲述了通过 send 和 read 来收发数据包,你需要牢记以下两点:
- 对于 send 来说,返回成功仅仅表示数据写到发送缓冲区成功,并不表示对端已经成功收到;
- 对于 read 来说,需要循环读取数据,并且需要考虑 EOF 等异常条件。
思考题
1、既然缓冲区如此重要,我们可不可以把缓冲区搞得大大的,这样不就可以提高应用程序的吞吐量了么?你可以想一想这个方法可行吗?
- 无限增大缓冲区肯定不行,write函数发送数据只是将数据发送到内核缓冲区,而什么时候发送由内核决定;
- 内核缓冲区总是充满数据时会产生粘包问题,同时网络的传输大小MTU也会限制每次发送的大小;
- 最后由于数据堵塞需要消耗大量内存资源,资源使用效率不高。
2、一段数据流从应用程序发送端,一直到应用程序接收端,总共经过了多少次拷贝?
6次,一边3次。
- 用户态内存拷贝到内核态内存 是一次(这里应该直接拷贝到发送换冲区了);
- 传输层组TCP包是第二次拷贝,因为要加包头,而发送缓冲区的都是紧凑内存全是应用层数据,那么分装包就需要一次拷贝
- 一个TCP包封装为IP报文这里可能也会需要一次拷贝,毕竟这里走到协议栈的下一层了。
老师的答案:
![img](https://static001.geekbang.org/resource/image/50/8f/50c05c8509a1d8436273adbf8701bb8f.png)
发送端:
- 先看发送端,当应用程序将数据发送到发送缓冲区时,调用的是 send 或 write 方法,如果缓存中没有空间,系统调用就会失败或者阻塞。我们说,这个动作事实上是一次“显式拷贝”。
- 在这之后,数据将会按照 TCP/IP 的分层再次进行拷贝,这层的拷贝对我们来说就不是显式的了。
- 接下来轮到 TCP 协议栈工作,创建 Packet 报文,并把报文发送到传输队列中(qdisc),传输队列是一个典型的 FIFO 队列,队列的最大值可以通过 ifconfig 命令输出的 txqueuelen 来查看。通常情况下,这个值有几千报文大小。
- TX ring 在网络驱动和网卡之间,也是一个传输请求的队列。
- 网卡作为物理设备工作在物理层,主要工作是把要发送的报文保存到内部的缓存中,并发送出去。
接收端:
-
报文首先到达网卡,由网卡保存在自己的接收缓存中;
-
接下来报文被发送至网络驱动和网卡之间的 RX ring,网络驱动从 RX ring 获取报文 ,然后把报文发送到上层。
-
注意,网络驱动和上层之间没有缓存,因为网络驱动使用 Napi 进行数据传输。因此,可以认为上层直接从 RX ring 中读取报文。