极客时间已完结课程限时免费阅读

05 | 使用套接字进行读写:开始交流吧

05 | 使用套接字进行读写:开始交流吧-极客时间

05 | 使用套接字进行读写:开始交流吧

讲述:冯永吉

时长10:19大小9.44M

你好,我是盛延敏,这里是网络编程实战第 5 讲,欢迎回来。
在前面的章节中,我们讲述了套接字相关的知识,包括套接字的格式,套接字的创建以及 TCP 连接的建立等。在这一讲里,我来讲一下如何使用创建的套接字收发数据。
连接建立的根本目的是为了数据的收发。拿我们常用的网购场景举例子,我们在浏览商品或者购买货品的时候,并不会察觉到网络连接的存在,但是我们可以真切感觉到数据在客户端和服务器端有效的传送, 比如浏览商品时商品信息的不断刷新,购买货品时显示购买成功的消息等。
首先我们先来看一下发送数据。

发送数据

发送数据时常用的有三个函数,分别是 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 函数举例,重点阐述发送缓冲区的概念。

发送缓冲区

你一定要建立一个概念,当 TCP 三次握手成功,TCP 连接成功建立后,操作系统内核会为每一个连接创建配套的基础设施,比如发送缓冲区
发送缓冲区的大小可以通过套接字选项来改变,当我们的应用程序调用 write 函数时,实际所做的事情是把数据从应用程序中拷贝到操作系统内核的发送缓冲区中,并不一定是把数据通过套接字写出去。
这里有几种情况:
第一种情况很简单,操作系统内核的发送缓冲区足够大,可以直接容纳这份数据,那么皆大欢喜,我们的程序从 write 调用中退出,返回写入的字节数就是应用程序的数据大小。
第二种情况是,操作系统内核的发送缓冲区是够大了,不过还有数据没有发送完,或者数据发送完了,但是操作系统内核的发送缓冲区不足以容纳应用程序数据,在这种情况下,你预料的结果是什么呢?报错?还是直接返回?
操作系统内核并不会返回,也不会报错,而是应用程序被阻塞,也就是说应用程序在 write 函数调用处停留,不直接返回。术语“挂起”也表达了相同的意思,不过“挂起”是从操作系统内核角度来说的。
那么什么时候才会返回呢?
实际上,每个操作系统内核的处理是不同的。大部分 UNIX 系统的做法是一直等到可以把应用程序数据完全放到操作系统内核的发送缓冲区中,再从系统调用中返回。怎么理解呢?
别忘了,我们的操作系统内核是很聪明的,当 TCP 连接建立之后,它就开始运作起来。你可以把发送缓冲区想象成一条包裹流水线,有个聪明且忙碌的工人不断地从流水线上取出包裹(数据),这个工人会按照 TCP/IP 的语义,将取出的包裹(数据)封装成 TCP 的 MSS 包,以及 IP 的 MTU 包,最后走数据链路层将数据发送出去。这样我们的发送缓冲区就又空了一部分,于是又可以继续从应用程序搬一部分数据到发送缓冲区里,这样一直进行下去,到某一个时刻,应用程序的数据可以完全放置到发送缓冲区里。在这个时候,write 阻塞调用返回。注意返回的时刻,应用程序数据并没有全部被发送出去,发送缓冲区里还有部分数据,这部分数据会在稍后由操作系统内核通过网络发送出去。

读取数据

我们可以注意到,套接字描述本身和本地文件描述符并无区别,在 UNIX 的世界里万物都是文件,这就意味着可以将套接字描述符传递给那些原先为处理本地文件而设计的函数。这些函数包括 read 和 write 交换数据的函数。

read 函数

让我们先从最简单的 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 跳出循环后,返回实际读取的字符数。

缓冲区实验

我们用一个客户端 - 服务器的例子来解释一下读取缓冲区和发送缓冲区的概念。在这个例子中客户端不断地发送数据,服务器端每读取一段数据之后进行休眠,以模拟实际业务处理所需要的时间。

服务器端读取数据程序

下面是服务器端读取数据的程序:
#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 长度的字符串流发送出去

实验一: 观察客户端数据发送行为

客户端程序发送了一个很大的字节流,程序运行起来之后,我们会看到服务端不断地在屏幕上打印出读取字节流的过程:
而客户端直到最后所有的字节流发送完毕才打印出下面的一句话,说明在此之前 send 函数一直都是阻塞的,也就是说阻塞式套接字最终发送返回的实际写入字节数和请求字节数是相等的。
而关于非阻塞套接字的操作,我会在后面的文章中讲解。

实验二: 服务端处理变慢

如果我们把服务端的休眠时间稍微调大,把客户端发送的字节数从 10240000 调整为 1024000,再次运行刚才的例子,我们会发现,客户端很快打印出一句话:
但与此同时,服务端读取程序还在屏幕上不断打印读取数据的进度,显示出服务端读取程序还在辛苦地从缓冲区中读取数据。
通过这个例子我想再次强调一下:
发送成功仅仅表示的是数据被拷贝到了发送缓冲区中,并不意味着连接对端已经收到所有的数据。至于什么时候发送到对端的接收缓冲区,或者更进一步说,什么时候被对方应用程序缓冲所接收,对我们而言完全都是透明的。

总结

这一讲重点讲述了通过 send 和 read 来收发数据包,你需要牢记以下两点:
对于 send 来说,返回成功仅仅表示数据写到发送缓冲区成功,并不表示对端已经成功收到。
对于 read 来说,需要循环读取数据,并且需要考虑 EOF 等异常条件。

思考题

最后你不妨思考一下,既然缓冲区如此重要,我们可不可以把缓冲区搞得大大的,这样不就可以提高应用程序的吞吐量了么?你可以想一想这个方法可行吗?另外你可以自己总结一下,一段数据流从应用程序发送端,一直到应用程序接收端,总共经过了多少次拷贝?
欢迎你在评论区与我分享你的答案,如果你理解了套接字读写的过程,也欢迎把这篇文章分享给你的朋友或者同事。
分享给需要的人,Ta购买本课程,你将得18
生成海报并分享

赞 26

提建议

上一篇
04 | TCP三次握手:怎么使用套接字格式建立连接?
下一篇
06 | 嗨,别忘了UDP这个小兄弟
unpreview
 写留言

精选留言(113)

  • 破晓^_^
    2019-08-12
    无限增大缓冲区肯定不行,文章中已经说过write函数发送数据只是将数据发送到内核缓冲区,而什么时候发送由内核觉定。内核缓冲区总是充满数据时会产生粘包问题,同时网络的传输大小MTU也会限制每次发送的大小,最后由于数据堵塞需要消耗大量内存资源,资源使用效率不高。 用户缓冲区到内核缓冲区 内核缓冲区IP报文,一次三拷贝,总共6次。不知对否?
    展开

    作者回复: 都是强人😄

    共 19 条评论
    71
  • 莫珣
    2020-01-15
    无限大肯定是不行的,这要从为什么使用缓存这个角度考虑。内核协议栈不确定用户一次要发多少数据,如果用户来一次就发一次,如果数据多还好说,如果少了,那网络I/O很频繁,而真正发送出去的数据也不多,所以为了减少网络I/O使用了缓存的策略。但为啥不呢无限大呢,网卡一次发出去的数据报它是有一个最大长度的,所以你不管累积再多数据最后还是要分片发送的,这样一来缓冲区太大也没什么意义,而且数据传输也是有延时要求的,不可能总是在缓冲区里待着等数据,这样就总会有空出来的缓冲区存放新数据,所以无限大缓冲区也没意义,反而还浪费资源。 发送端,假设数据能一次性复制完,那么从用户态内存拷贝到内核态内存是一次(这里应该直接拷贝到发送换冲区了),传输层组TCP包是第二次拷贝,因为要加包头,而发送缓冲区的都是紧凑内存全是应用层数据,那么分装包就需要一次拷贝,第三次,一个TCP包封装为IP报文这里可能也会需要一次拷贝,毕竟这里走到协议栈的下一层了。
    展开

    作者回复: 总结的很牛

    共 6 条评论
    45
  • cool
    2020-04-26
    什么是粘包问题?怎么解决

    作者回复: TCP是流协议,根本不存在所谓粘包一说。应用层协议在设计的时候,是需要充分考虑到数据解析和还原的问题,如果设计不好,导致数据无法还原,那是应用层协议设计不佳,并不是说TCP天然有粘包问题。

    26
  • fjpcode
    2019-08-13
    1. 缓冲区搞大一些,在一定程度上能够减少write/send等系统调用,减少用户空间和内核空间的切换。但是和吞吐量并没有直接关系, 还可能导致数据都挤压到内核缓冲区得不到处理,造成内存消耗的问题。 2. 发送方:用户空间-->内核缓冲区-->报文封装 三次拷贝,接收方反向过来,所以应该是6次。
    共 4 条评论
    22
  •  
    2019-08-13
    尝试着照着老师贴出来的代码写了一个,可以跑起来 https://github.com/yingcheng-zhou/socket-reading-and-writing

    作者回复: 👍

    共 8 条评论
    21
  • WhatAKitty
    2020-04-12
    不涉及协议栈层面,应该是4次: 用户缓冲区 -> 内核缓冲区 -> 网卡 -> 对端网卡 -> 内核缓冲区 -> 用户缓冲区 老师这里提及复制几次,主要是为了引出零拷贝吧。直接由用户缓冲区复制到网卡DMA区域。减少了中间经由内核缓冲区中转的过程。

    作者回复: DMA区域都知道,🐂。

    共 5 条评论
    13
  • itschenxiang
    2019-09-03
    关于write函数的返回值那里还是不太懂,当它的返回值(数值大小)小于期望写入的字节数,那它的值代表什么呢???

    作者回复: 表示缓冲区就那么大,装不下你要的那么大的字节流,就返回了目前能装下的部分,剩下的部分应用程序要自己接着往里装。

    共 3 条评论
    10
  • 何赫赫
    2020-03-07
    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; } 老师你好,send函数不是会等所有的数据都放入缓冲区后才返回吗,那返回的n_written不是应该等于remaining呀,为什么还需要while循环
    展开

    作者回复: 在非阻塞I/O的情况下,send函数是"能写多少写多少",所以n_written就不等于remaining了,而send函数为了同时对阻塞I/O和非阻塞I/O起作用,就用while循环了。

    9
  • 郑祖煌
    2020-06-12
    增大一些是可以提高系统的效率,一定程度上减少了write/send调用,减少了用户空间和内核之间的切换。但是并不能增大吞吐量,毕竟内核的缓冲区并不能跟用户空间的缓冲区保持同步增大。把内核缓冲区总是满满的会增加粘包的频率和概率。

    作者回复: 👍

    6
  • 传说中的成大大
    2019-08-13
    还有就是通过现象说明 应用程缓冲区虽然是10240000但是套接字缓冲区应该不是10240000这么大它一次性没写完,这也是为啥服务器端在不停的打印收到的字节数 这说明在客户端send未完全拷贝到套接字缓冲区之前他是不会返回的
    5
  • Geek_Wison
    2019-08-12
    老师可以将完整的代码的github地址贴出来吗,我想自己编译调试运行一下。

    作者回复: 正在进行中

    共 5 条评论
    5
  • tianheng31
    2020-02-18
    想请问老师两个问题: 【1】“如果我们把服务端的休眠时间稍微调大,把客户端发送的字节数从从 10240000 调整为1024000”,这里表述是否有点小偏差,是从102400调整为2014000吗? 【2】“对我们而言完全都是透明的”,既然我们不知道对方什么时候接收完毕,是否应该是不透明的呢?

    作者回复: 1.是减少一个0. 2.我想表达的意思是TCP协议给我们提供了一个透明的交互方式,数据的接收是实现的细节,我们并不需要知道这个细节。如果你从要感知这个细节角度出发,确实是不透明的。

    共 2 条评论
    4
  • 学怪
    2019-12-06
    网络编程中为什么要循环读取数据呢?

    作者回复: 因为数据像流水一样,不会结束,所以叫做stream流。

    共 2 条评论
    4
  • itschenxiang
    2019-10-04
    在 UNP 中关于 read 和 write 函数有这样一段描述:字节流套接字上调用 read 或 write 输入或输出的字节数可能比请求的数量少......这个现象在 read 一个字节流套接字时很常见,但是在 write 一个字节流套接字时只能在该套接字为非阻塞的前提下才出现,请问这里是什么意思?

    作者回复: 就是说读的时候可能读不到你指定大小的字节流;而发送的时候如果是阻塞套接字,则会全部发送完;只有当非阻塞套接字的时候,才可能发送的字节数和请求的字节数不一致。该系列的后面几讲会讨论到这方面的内容。

    3
  • tongmin_tsai
    2019-09-30
    老师,如果客户端和服务端要求是一次短链接,并且是一次性发完所有数据,那如果客户端的缓冲区大,服务端的缓冲区小,那么服务端如何能知道客户端这次数据完全发送完毕的?比如客户端发送1000字节,客户端的缓存区大小为1200字节,那客户端可以一次性把数据放到缓冲区,服务端这边,缓冲区大小为400字节,那么服务端是否就相当于要从缓冲区读取数据3次了,那么服务端是如何知道客户端数据发送完毕的?

    作者回复: 首先,TCP的报文会被封装成一个一个TCP包,每个包都有一个sequence序列号,每个包里包含了一定的字节,当这个包被接收端接收(放到接收缓冲区中),接收端发送一个ACK,这个ACK和sequence对应,这样服务端就可以知道哪些包被接收,哪些包没有被接收。 按照你的例子,我们以400为包大小,发送了三个ACK,就可以认为1200字节发送结束。 服务端是不需要知道数据是否发送完毕的,因为TCP是一个流式的,没有办法知道客户端下个时刻还会不会发送数据,服务端只要告诉客户端我收到了1200字节就可以了。

    共 4 条评论
    3
  • 徐凯
    2019-08-12
    我想问一下 用户态缓存是否指的是运行库的缓存 我的意思就是 write最终是要调用系统调用的 而我们使用的是运行库的write函数 为了避免像发一个字节就立马调用系统调用 运行库也会有缓存来尽量减少系统调用的次数 这个是不是就是指的是用户态缓存 而不是指用户在程序中自己定义的一段buffer数组对吧

    作者回复: 我理解不是这样的,咱们调用write就是一个系统调用,就会有用户态-内核态的上下文切换,你说的这个问题,确实是实战中应该尽量避免的,我在后面的提高篇中会针对你说的这个情况讲到一些技巧。

    3
  • Sweety
    2019-08-12
    对C不了解,有点不好理解. 只能将就的看懂方法. 第一次认真跟课,就给自己挖了一个坑. 理解思想吧

    作者回复: 答疑篇会稍微点拨一下C语言

    3
  • 旅途
    2019-12-29
    老师 问一下 send into buffer 这句话 看代码里 应该是每次 发送完都会打印出来 为什么调小发送字节数后才能打出来呢?

    作者回复: 如果你问的是第二个实验的结果,其实是这样的,确实每次发送都会打印出"send into buffer"这句话,问题是这里的程序一次性的将query字符串发送到了发送缓冲区,而发生缓冲区如果足够大,那么是可以一次性的容纳这部分数据的,所以当我们把发送字节数从从 10240000 调整为 1024000,就会直接看到"send into buffer"这句话

    共 2 条评论
    2
  • itgou
    2019-11-21
    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); /* 关闭连接套接字,注意不是监听套接字 */ } 两点不明白: 1,bzero函数做了什么事; 2,cliaddr客户端地址只是申明了一下,就传给accept函数了,有什么意义呢?
    展开

    作者回复: 1.bzero对地址进行了置0处理,相当于初始化。否则地址就是错的; 2.cliaddr在accept函数中是传了一个地址的,这相当于是accept函数的输出参数,这样就可以知道连接上来的客户端的信息,比如IP。

    共 2 条评论
    2
  • 禾桃
    2019-08-16
    13-14 行表示的是非阻塞 I/O 的情况下,没有数据可以读,需要继续调用 read。 EINTR The call was interrupted by a signal before any data was read; see signal(7). 貌似这个场景是因为read这个函数在执行过程中被一个信号中断,而没有执行完提前退出了, 您的意思是只有在socket被设置为非阻塞的前提下,才会出现? 如果socket之前被设成阻塞,read就不会返回,直到这个信号被处理了,如果处理后返回这个进程,读取的操作会继续进行下去,直到read函数返回? 多谢!
    展开

    作者回复: 阻塞那部分确实是这样的,当然,可以为read设置超时。

    共 3 条评论
    2