目录
再谈"协议"
序列化
JSON
网络版计算器
HTTP协议
认识URL
urlencode和urldecode
HTTP协议格式
telnet指令
stat函数
struct stat类型
stringstream类型
wget指令
HTTP的方法
HTTP的状态码
传输层
再谈端口号
端口号范围划分
认识知名端口号(Well-Know Port Number)
netstat命令
pidof命令
UDP协议
UDP协议端格式
UDP的特点
面向数据报
UDP的缓冲区
UDP使用注意事项
基于UDP的应用层协议
TCP协议
TCP协议段格式
确认应答机制、4位首部长度
序号和确认信号及超时重传机制
16位窗口大小、流量控制
6位标志位
连接管理机制
理解TIME_WAIT状态
setsockopt函数
滑动窗口
拥塞控制
延迟应答
TCP小结
面相字节流
粘包问题
TCP异常情况
用UDP实现可靠传输(经典面试题)
理解listen的第二个参数
再谈"协议"
协议是一种 "约定". socket api的接口, 在读写数据时, 都是按 "字符串" 的方式来发送接收的,如果我们要传输一些"结构化的数据" 怎么办呢?其实方法的形式也是差不多,也是以字符串的形式进行发数据和收数据,只不过是把这个数据结构整体当成一个大的字符串(字节流)并且加上特定的协议,例如例如,长度前缀、分隔符等再进行发送,这个过程称为序列化,而接受方也需要知道和遵守这个特定的协议并通过这个协议把接受的字符串数据重新转化为数据结构用来的样子,这个过程称为反序列化。序列化和反序列化主要是为了方便网络通信,而协议本质就是双方或者多人约定好的某种格式的数据(字符串)。
协议约定方案一:
客户端发送一个形如"1+1"的字符串;
这个字符串中有两个操作数, 都是整形;
两个数字之间会有一个字符是运算符, 运算符只能是 + ;
数字和运算符之间没有空格;
...
协议 约定方案二:
定义结构体来表示我们需要交互的信息;
发送数据时将这个结构体按照一个规则转换成字符串, 接收到数据的时候再按照相同的规则把字符串转化回结构体;
这个过程叫做 "序列化" 和 "反序列化"
- 序列化和反序列化:
-
- 将结构化数据转换为字节流进行发送(序列化)和接收后再转换回结构化数据(反序列化)是常见的做法。这意味着你需要定义一种协议,在发送数据之前将结构化数据转换为字节流,接收后将字节流还原为结构化数据。
- 常用的序列化方法包括 JSON、XML、Protocol Buffers、MessagePack 等。你可以根据需求选择合适的序列化方式。
- 定义消息格式:
-
- 在传输数据之前,定义一种消息格式,指定数据的结构和字段。可以按照一定的规则(例如,长度前缀、分隔符等)将数据打包为消息,发送方和接收方都遵循相同的协议来解析数据。
注:"添加的协议"这个表述可能有些含糊,通常协议指的是通信双方就数据交换达成的约定或规范。在网络通信中,协议可以涵盖通信的各个方面,包括报头、数据格式、传输方式、错误处理等。
报头(Header)则是一种协议中的组成部分,它是在数据传输中用于描述数据的元数据。报头包含了关于数据的一些信息,例如数据的长度、数据类型、编码方式、身份验证信息等。报头的内容可以根据具体的协议和应用而有所不同。
所以,可以说协议可能包含报头这样的元数据信息,但是协议本身更广泛,涵盖了通信过程中的规范、约定和规则,而报头则是其中的一个具体组成部分,用于在数据传输中携带元数据信息。
序列化
序列化(Serialization)和反序列化(Deserialization)是将对象转换为字节流的过程,以便可以将其存储在内存中、通过网络进行传输,或者将其保存到持久存储(如磁盘)中。这个过程的主要目的是在需要时能够重新创建原始对象。这在分布式系统、数据持久化、进程间通信等场景中都是常见的操作。
- 序列化: 将对象转换为字节流的过程。序列化后的字节流可以被传输、存储,或者用于其他需要将对象表示为字节序列的场景。在序列化过程中,对象的状态信息被转换为字节码,这通常包括对象的数据成员、类型信息以及可能的对象关系。
- 反序列化: 将序列化的字节流重新转换为对象的过程。反序列化的目的是重建原始对象,以便在程序中使用。在反序列化过程中,字节码被还原为对象的状态信息,从而重新创建具有相同状态的对象。
JSON
JSON(JavaScript Object Notation)是一种轻量级的数据交换格式,它易于人阅读和编写,也易于机器解析和生成。JSON是基于JavaScript的一个子集,但由于其简洁性和通用性,已经成为一种独立的数据格式,并在许多编程语言中得到广泛支持。
JSON采用键值对的方式组织数据,数据被表示为一个对象(object),对象由一系列无序的键值对组成。JSON还支持数组(array),数组是一个有序的值的集合。
查看安装包:
安装指令:
在C++中,要处理JSON数据通常需要使用第三方的JSON库,因为C++标准库并没有提供直接的JSON支持。下面是一些常用的C++ JSON库:
jsoncpp:
描述: jsoncpp 是一个流行的C++ JSON库,它提供了用于解析和生成JSON数据的接口。它支持面向对象的方式访问JSON数据。
#include <iostream>
#include <json/json.h>
int main() {
Json::Value root;
root["name"] = "John";
root["age"] = 30;
std::cout << root << std::endl;
return 0;
}
注:在进行编译时需要带上库名称,因为是第三方库,如:
g++ -o $@ $^ -std=c++11 -ljsoncpp
网络版计算器
例如, 我们需要实现一个服务器版的加法器. 我们需要客户端把要计算的两个加数发过去, 然后由服务器进行计算, 最后再把结果返回给客户端。
HTTP协议
虽然我们说,应用层协议是我们程序猿自己定的。
但实际上,已经有大佬们定义了一些现成的,又非常好用的应用层协议, 供我们直接参考使用,HTTP(超文本传输协议)就是其中之一
认识URL
平时我们俗称的 "网址或者超链接" 其实就是说的 URL
urlencode和urldecode
像 / ? : 等这样的字符, 已经被url当做特殊意义理解了. 因此这些字符不能随意出现。比如, 某个参数中需要带有这些特殊字符, 就必须先对特殊字符进行转义.
转义的规则如下:
将需要转码的字符转为16进制,然后从右到左,取4位(不足4位直接处理),每2位做一位,前面加上%,编码成%XY格式例如:
"+" 被转义成了 "%2B"
urldecode就是urlencode的逆过程;
可以在网上搜索urlencode工具。
HTTP协议格式
1.http请求:
- 首行: [版本号] + [状态码] + [状态码解释]
- Header: 请求的属性, 冒号分割的键值对;每组属性之间使用\n分隔;遇到空行表示Header部分结束
- Body: 空行后面的内容都是Body. Body允许为空字符串. 如果Body存在, 则在Header中会有一个
Content-Length属性来标识Body的长度; 如果服务器返回了一个html页面, 那么html页面内容就是在body中。
HTTP 长连接和短连接是指在客户端和服务器之间建立和维持连接的不同方式。
- 短连接:
-
- 在短连接中,每个请求和响应都使用单独的连接。当请求完成后,连接会被立即关闭。
- 短连接的优点是简单,适用于一次性请求和响应的场景。
- 每次请求都需要重新建立连接,可能会引起一些额外的开销,尤其是在频繁请求的情况下。
- 长连接:
-
- 在长连接中,一次连接可以用于多个请求和响应。连接在处理完请求后不会立即关闭,而是保持开放状态一段时间,以便后续的请求可以复用同一连接。
- 长连接可以降低连接建立和断开的开销,提高性能,尤其在多次请求和响应的情况下。
- HTTP/1.1 引入了持久连接的概念,使得在默认情况下,连接可以被重用。
对于长连接,可以使用以下两个头部来控制:
- Connection:
-
- Connection: keep-alive 表示希望使用持久连接。
- Connection: close 表示在请求完成后关闭连接。
- Keep-Alive:
-
- Keep-Alive: timeout=30, max=100 表示在连接空闲 30 秒后可能关闭连接,最多处理 100 个请求。
长连接的使用有助于减少连接的创建和断开次数,从而提高性能。在一些现代的应用中,特别是在使用 HTTP/1.1 或更高版本的协议时,长连接变得更为普遍。HTTP/2 协议更是在协议层面支持了多路复用,使得多个请求可以并行传输在同一个连接上。
2.http 响应
以下是一些常见的 HTTP 响应头字段和它们的含义:
- HTTP 状态行:
-
- HTTP/1.0 200 OK: 表示 HTTP 版本是 1.0,状态码是 200,表示请求成功。
- 常见的响应头字段:
-
- Accept-Ranges: bytes: 表示服务器支持按字节范围请求(断点续传)。
- Cache-Control: no-cache: 指示缓存机制不应缓存响应。
- Content-Length: 9508: 表示响应正文(有效载荷)的长度为 9508 字节。
- Content-Type: text/html: 指示响应正文是 HTML 格式的文本。
注:Content-Type(种类)对照表:HTTP Content-Type对照表 - MKLab在线工具
-
- Date: Thu, 11 Jan 2024 09:30:32 GMT: 表示响应生成的日期和时间。
- Server: BWS/1.1: 表示服务器的类型和版本信息。
- Cookie 相关的响应头字段:
-
- Set-Cookie: 用于在客户端设置 Cookie,用于在客户端存储少量信息.。通常用于实现会话(session)的保持功能。(关于会话的保持功能,http本身是无状态的,列如登录某一个网站(b站等),我们只有第一次打开网站时需要输入账号密码,当我们关闭网站时重新打开,网站还是保持着登录状态的,这就是cookie在我们本地存储了一些私人信息的原因,Set-Cookie 是 HTTP 头部中的一部分,用于在客户端设置 Cookie。Cookie 是服务器通过 HTTP 头部中的 Set-Cookie 字段发送到客户端,然后客户端将这个 Cookie 存储在本地。这样,客户端在后续的 HTTP 请求中就可以通过将 Cookie 包含在请求头中,将存储的信息发送回服务器。)
- 其他头字段:
-
- P3p: 指定了网站的 P3P 隐私策略。
- Pragma: no-cache: 与 Cache-Control 类似,指示缓存机制不应缓存响应。
- Traceid: 一个追踪标识符,用于跟踪请求和响应之间的关联。
- Vary: Accept-Encoding: 指示代理服务器应根据请求头中的 Accept-Encoding 字段来进行响应的选择。
- X-Ua-Compatible: IE=Edge,chrome=1: 提供有关用户代理(浏览器)的信息。
- Host: 客户端告知服务器, 所请求的资源是在哪个主机的哪个端口上;
- User-Agent: 声明用户的操作系统和浏览器版本信息;
- referer: 当前页面是从哪个页面跳转过来的;
- location: 搭配3xx状态码使用, 告诉客户端接下来要去哪里访问;
这只是响应头的一部分,通常还会有响应正文包含实际的数据。根据响应头的信息,可以了解到响应的状态、内容类型、服务器信息等。
注:在上面这些所有字段中,例如代码中的:
像这些代码中的 "Locatio" 、”Set-Cookie"以及其他字段,它们都是KV型结构的格式 ":" 的左边是key,右边是value(value有我们来填写),我们在编写代码的时候尽管使用就行,它们都是http的内定协议所以不需要我们手动解析,Location 和 Set-Cookie 是 HTTP 头部中的字段,它们是 HTTP 协议定义的标准字段,用于在请求和响应中传递特定的信息。这些字段在 HTTP 协议中有明确定义的语法和语义,而且浏览器客户端和服务器在处理 HTTP 消息时都会按照协议规范来解析这些字段。
具体来说:
- Location 字段用于指示客户端应该重定向到的新的资源位置。客户端会自动解析这个字段,并按照其中指定的地址发起新的请求。
- Set-Cookie 字段用于在客户端设置 Cookie。服务器发送带有 Set-Cookie 头部的响应后,客户端会自动存储这些 Cookie,并在后续的请求中将其包含在请求头中发送回服务器。
这些字段的使用是符合 HTTP 协议规范的,因此在编写代码时,你只需按照协议规定的方式设置这些字段的值,而不需要手动解析它们。浏览器和服务器会负责处理这些字段,确保它们按照协议规范进行解释和执行相应的操作。
telnet指令
telnet 是一个用于远程登录和管理网络设备的命令行工具(如登录网页或者其他计算机等)。它通常用于测试网络连接、访问远程服务器以及访问本地服务器和调试网络服务。以下是 telnet 命令的基本用法:
基本语法:
telnet [options] [hostname [port]]
- options:一些可选的参数,用于配置 telnet 的行为。
- hostname:要连接的目标主机的名称或 IP 地址。
- port:要连接的目标端口号。如果未指定,将使用默认端口 23。
示例:
- 基本连接:
telnet example.com
这将尝试连接到 example.com 的默认 telnet 端口(端口 23)。
- 指定端口号:
telnet example.com 8080
这将尝试连接到 example.com 的端口号为 8080 的服务。
- 退出 telnet:在 telnet 会话中,可以使用 Ctrl+] 进入 telnet 命令提示符,然后按下回车显示空行,之后可以对连接的服务器或者主机发送指令(消息)。再次使用 Ctrl+] 进入 telnet 命令提示符,然后输入 quit 或 exit 来退出 telnet 会话,这会结束Telnet程序,并关闭与远程主机的连接。
- 使用选项:
telnet -a example.com
-a 选项表示使用二进制模式。其他选项可以根据需要进行调整。
注意事项:
- telnet 传输数据是明文的,因此不安全。推荐使用更安全的替代方法,如 SSH(Secure Shell)来进行远程登录。
- 在一些 Linux 发行版中,telnet 可能未安装,默认情况下不安装。你可能需要通过包管理器安装它,比如:
sudo apt-get install telnet
或
sudo yum install telnet
请注意,由于安全性问题,现代系统和网络更倾向于使用 SSH 而非 telnet。
3.模拟一个简单的response
stat函数
stat 函数是一个用于获取文件或文件系统状态的 POSIX 标准的系统调用。它返回包含有关文件的信息的结构体。在C语言中,stat 函数通常用于获取文件的元数据,如文件大小、修改时间、创建时间等。
以下是 stat 函数的基本原型:
int stat(const char *path, struct stat *buf);
- path:指定要获取信息的文件路径。
- buf:用于存储文件信息的结构体,通常是 struct stat 类型。
stat 函数的返回值是一个整型值,用于指示函数调用的成功与否。返回值如下:
- 如果 stat 函数成功执行并获取到文件信息,返回值为 0(0 表示成功)。
- 如果 stat 函数执行失败,返回值为 -1(-1 表示失败),并设置全局变量 errno 表示具体的错误原因。
当 stat 函数返回 -1 时,可以通过查看 errno 变量的值来判断具体的错误类型。常见的错误类型包括:
- ENOENT:文件不存在。
- EACCES:权限不足,无法访问文件。
- EFAULT:传递给 stat 函数的指针参数无效。
以下是一个简单的示例,展示如何使用 stat 函数获取文件信息:
#include <stdio.h>
#include <sys/stat.h>
#include <unistd.h>
int main() {
const char *file_path = "example.txt";
struct stat file_info;
// 使用 stat 获取文件信息
if (stat(file_path, &file_info) == 0) {
// 打印文件大小
printf("File Size: %ld bytes\n", file_info.st_size);
// 打印修改时间
printf("Last Modified Time: %s", ctime(&file_info.st_mtime));
// 可以继续输出其他文件信息
} else {
perror("Error in stat");
}
return 0;
}
在这个示例中,stat 函数被用于获取指定文件的信息,并将结果存储在 struct stat 结构体中。然后,通过访问结构体的成员,可以获取文件的各种信息。
请注意,具体的文件信息存储在 struct stat 结构体中的各个成员中,例如 st_size 表示文件大小,st_mtime 表示最后修改时间。这些成员的含义可以根据需要进行查阅。
struct stat类型
struct stat 是用于存储文件或文件系统状态信息的结构体类型。在C语言中,这个结构体类型通常由系统定义,它包含了多个字段,每个字段都存储了不同类型的文件信息。以下是 struct stat 结构体的一些常见字段:
struct stat {
dev_t st_dev; // 设备 ID
ino_t st_ino; // 文件的 i 节点号
mode_t st_mode; // 文件的类型和权限
nlink_t st_nlink; // 连接数目
uid_t st_uid; // 文件所有者的用户 ID
gid_t st_gid; // 文件所有者的组 ID
dev_t st_rdev; // 若文件为设备文件,则为其设备的 ID
off_t st_size; // 文件大小(以字节为单位)
blksize_t st_blksize; // 文件系统块的大小
blkcnt_t st_blocks; // 文件占用的块数
time_t st_atime; // 最后一次访问时间
time_t st_mtime; // 最后一次修改时间
time_t st_ctime; // 最后一次状态改变时间
};
这些字段包含了文件的基本属性,例如文件类型和权限 (st_mode)、文件所有者的用户 ID 和组 ID (st_uid 和 st_gid)、文件大小 (st_size) 以及最后一次访问、修改和状态改变的时间戳 (st_atime、st_mtime 和 st_ctime) 等信息。
在使用 stat 函数时,你将提供一个指向这个结构体的指针,并在函数成功调用后,该结构体将被填充以包含有关指定文件的信息。例如,你可以通过 st_size 成员获取文件的大小,通过 st_mtime 成员获取最后修改的时间戳等。
stringstream类型
std::stringstream 是 C++ 标准库中的一个类,它提供了对字符串的输入和输出操作,允许你像使用流一样对字符串进行处理。它是基于 std::istream 和 std::ostream 的,因此你可以使用类似于流的语法来读取和写入数据。
以下是 std::stringstream 的一些基本用法:
#include <iostream>
#include <sstream>
int main() {
// 创建一个 stringstream 对象
std::stringstream ss;
// 写入数据到 stringstream
ss << "Hello, ";
ss << 42;
ss << " C++!";
// 从 stringstream 读取数据
std::string result = ss.str();
// 输出结果
std::cout << result << std::endl;
return 0;
}
在这个例子中,我们创建了一个 std::stringstream 对象 ss,然后使用 << 操作符将字符串和整数写入到 stringstream 中。最后,通过 str() 成员函数,我们将 stringstream 中的数据作为一个字符串获取出来,并将其输出到标准输出流。
std::stringstream 可以用于将不同类型的数据组合成一个字符串,也可以用于从字符串中提取不同类型的数据。这在处理配置文件、解析用户输入等场景中非常有用。
以下是一个简单的示例演示从字符串中提取整数:
#include <iostream>
#include <sstream>
int main() {
std::string input = "123 456 789";
std::stringstream ss(input);
int num1, num2, num3;
// 从 stringstream 中提取整数
ss >> num1 >> num2 >> num3;
// 输出提取的整数
std::cout << "Numbers: " << num1 << ", " << num2 << ", " << num3 << std::endl;
return 0;
}
这个例子中,我们使用 >> 操作符从 stringstream 中提取整数,并将其输出到标准输出流。
wget指令
wget 是一个在命令行下用于从网络下载文件(如:图片、音频、视频....)的工具。它支持 HTTP、HTTPS 和 FTP 等协议,提供了许多选项和功能,使其成为一个功能强大的下载工具。以下是一些常用的 wget 命令和选项:
- 基本用法:
wget [options] [URL]
- 下载文件到当前目录:
wget http://example.com/file.zip
- 指定保存文件名:
wget -O output-file.zip http://example.com/file.zip
- 后台下载:
wget -b http://example.com/largefile.zip
- 断点续传:
wget -c http://example.com/largefile.zip
- 递归下载整个网站:
wget --recursive --no-clobber --page-requisites --html-extension --convert-links --restrict-file-names=windows http://example.com
- 限制下载速度:
wget --limit-rate=200k http://example.com/file.zip
- 使用代理服务器:
wget --proxy=http://proxy.example.com:8080 http://example.com/file.zip
- 使用用户名和密码进行 HTTP 基本认证:
wget --http-user=username --http-password=password http://example.com/file.zip
- 显示详细的下载信息:
wget --progress=bar:force http://example.com/file.zip
- 查看 wget 版本信息:
wget --version
这只是 wget 命令的一些基本用法和选项。wget 支持许多其他选项,你可以使用 man wget 命令来查看完整的手册。
HTTP的方法
其中最常用的就是GET方法和POST方法,一般都是浏览器客户端进行发起的,它会构建一个http request,携带的方法GET/POST,但是浏览器要怎么知道我们使用的方法呢?我们要促使浏览器使用不同的方法进行资源请求/提交,我们要配合一个html的表单概念。
HTTP的状态码
HTTP 状态码是由服务器在响应 HTTP 请求时返回的三位数字代码。这个代码表示了请求的处理状态。状态码由 RFC 7231 规范定义,分为五个类别,每个类别有多个具体的状态码。
- 1xx(Informational):
-
- 100 Continue: 请求已被服务器接受,客户应继续发送请求的其余部分。
- 101 Switching Protocols: 服务器已经理解了客户的请求,同意切换协议。
- 2xx(Successful):
-
- 200 OK: 请求成功。
- 201 Created: 请求已经被实现,而且有一个新的资源已经依据请求的需要而建立。
- 204 No Content: 服务器成功处理了请求,但没有返回任何内容。
- 3xx(Redirection):
-
- 300 Multiple Choices: 请求的资源可同时具有多个表示形式。
- 301 Moved Permanently: 请求的资源已被永久移动到新位置。
- 302 Found: 请求的资源临时从不同的 URI 响应请求。
- 4xx(Client Error):
-
- 400 Bad Request: 服务器未能理解请求。
- 401 Unauthorized: 请求要求身份验证。
- 403 Forbidden: 服务器理解请求,但拒绝执行。
- 404 Not Found: 服务器未找到请求的资源。
- 5xx(Server Error):
-
- 500 Internal Server Error: 服务器遇到不可预知的情况。
- 501 Not Implemented: 服务器不支持请求的功能。
- 503 Service Unavailable: 服务器暂时不可用。
这只是 HTTP 状态码的一部分,还有其他状态码表示不同的情况。状态码提供了一种机制,使客户端了解服务器对其请求的处理结果,从而采取适当的行动。在浏览器开发者工具或服务器日志中,你经常会看到这些状态码。
最常见的状态码, 比如 200(OK), 404(Not Found), 403(Forbidden), 302(Redirect, 重定向), 504(Bad Gateway)
传输层
负责数据能够从发送端传输给接收端。
再谈端口号
端口号(Port)标识了一个主机上进行通信的不同的应用程序;
在TCP/IP协议中,用"源IP","源端口号","目的IP","目的端口号","协议号”(协议号用来表示使用的是TCP协议还UDP协议)这样一个五元组来标识一个通信(可以通过netstat -n查看);
端口号范围划分
- 0-1023:知名端口号, HTTP, FTP, SSH等这些广为使用的应用层协议,他们的端口号都是固定的。
- 1024-65535:操作系统动态分配的端口号.客户端程序的端口号,就是由操作系统从这个范围分配的。
认识知名端口号(Well-Know Port Number)
有些服务器是非常常用的,为了使用方便,人们约定一些常用的服务器,都是用以下这些固定的端口号:
- ssh服务器,使用22端口
- ftp服务器,使用21端口
- telnet服务器,使用23端口
- http服务器,使用80端口
- https服务器,使用443
执行下面的命令,可以看到知名端口号
cat /etc/services
我们自己写一个程序使用端口号时,要避开这些知名端口号
两个问题:
- 一个进程是否可以bind多个端口号?
- 通常情况下,一个进程可以通过调用bind系统调用绑定一个端口号。这表示进程将监听该端口以接收传入的连接或数据。对于单个进程而言,一般只能绑定一个特定的端口号。如果需要监听多个端口,通常需要创建多个进程或线程,每个绑定到不同的端口上。
- 一个端口号是否可以被多个进程bind?
- 在一般情况下,同一时间同一端口号只能由一个进程bind。端口号是用于标识不同网络服务或进程的数字,如果多个进程同时尝试绑定到相同的端口,将导致端口冲突。这样的冲突可能导致其中一个或多个进程无法正常工作。
需要注意的是,有一些特殊情况和网络配置允许多个进程共享同一端口,这通常涉及到复杂的负载均衡、代理或特殊的应用层协议处理。一般情况下,为了避免端口冲突和混乱,最好让每个进程绑定到不同的端口号
netstat命令
语法:netstat [选项]
功能:查看网络状态
常用选项:
- n拒绝显示别名,能显示数字的全部转化成数字
- I仅列出有在Listen(监听)的服务状态
- p显示建立相关链接的程序名
- t (tcp)仅显示tcp相关选项
- u (udp)仅显示udp相关选项
- a(all)显示所有选项,默认不显示LISTEN相关
pidof命令
在查看服务器的进程id时非常方便
语法:pidof [进程名]
功能:通过进程名,查看进程id
UDP协议
UDP协议端格式
- 源端口 (Source Port): 16比特,指定发送端的端口号。
- 目标端口 (Destination Port): 16比特,指定接收端的端口号。
- UDP长度 (Length): 16比特,表示UDP报文的最大长度,包括报头和数据。这个数值的最小单位是字节,因此报文的最小长度是8个字节。
- 校验和 (Checksum): 16比特,用于验证UDP报文在传输过程中的完整性。这个字段是可选的,可以为0表示未使用。
1.UDP的报头和有效载荷是如何分离的?
UDP的报头和有效载荷分离:UDP(用户数据报协议)的报文格式由两个主要部分组成:UDP报头和UDP有效载荷。
- UDP报头: UDP报头包含了一些必要的控制信息,如源端口、目标端口、长度和校验和等。UDP报头的长度是固定的,为8个字节。
- UDP有效载荷: UDP有效载荷是实际要传输的数据,例如应用层的消息或数据块。有效载荷的长度是可变的,取决于应用程序发送的数据量。
在UDP报文中,报头和有效载荷是连续存储的,即报头紧跟在有效载荷之后。因为UDP报头的长度是固定8字节,UDP长度是16比特,其范围是0到65535(2^16 - 1)字节。因此,UDP报文的最大长度是65535字节。最大UDP报文长度(65535) - UDP报头长度(8字节) = 65535 - 8 = 65527字节,所以有效载荷是65527字节。
2.UDP的有效载荷是如何做到交付的?
因为一个UDP报文是包含一个16位的目的端口号的,所以一个报文发送到我的主机上,就可以想办法根据目的端口号,向上交给应用层,bing该端口号的进程,所以最终我们可以根据目的端口号进行向上交付即分用的过程
具体流程如下:
- 端口号识别: 操作系统的网络协议栈会检查UDP报文中的目的端口号。
- 交付给应用层: 协议栈会将数据报文交付给相应端口号的应用程序或进程。如果该端口号有一个正在监听的应用程序,数据将传递给该应用程序的接收缓冲区。
- 应用层处理: 应用程序接收到数据后,进行相应的处理。UDP本身不提供流控制、错误恢复或重传机制,因此应用程序可能需要处理丢失的数据或进行额外的错误检测和处理。
UDP的特点
UDP传输的过程类似于寄信
- 无连接:知道对端的IP和端口号就直接进行传输,不需要建立连接;
- 不可靠:没有确认机制,没有重传机制;如果因为网络故障该段无法发到对方,UDP协议层也不会给应用层返回任何错误信息;
- 面向数据报:不能够灵活的控制读写数据的次数和数量;
面向数据报
应用层交给UDP多长的报文,UDP原样发送,既不会拆分,也不会合并;
用UDP传输100个字节的数据:
如果发送端调用一次sendto,发送100个字节,那么接收端也必须调用对应的一次recvfrom,接收100个字节; 而不能循环调用10次recvfrom,每次接收10个字节;
注:什么叫做面向数据报?首先 UDP 报文有自己报头的标准8字节长度,和16位的UDP长度,就注定了在操作系统层面上,UDP是能够知道自己的有效载荷是多长并且能够知道自己的有效载荷报文的完整性的,意思就是UDP它的报头和有效载荷有没读完,UDP是可以自己对自己负责的,不用层去操心,通过16位总长度减去8字节,他自动可以知道有效载荷有多长,操作系统从来不做任何浪费的事情,既然UDP知道能够自己吧有效载荷交付给上层。既然交了,它就能够保证这个数据一定是完整的,虽然UDP是不可靠的,但是和这个报文一定是独立的并不冲突,因为UDP一旦有残缺,那么UDP的校验和就一定无法通过!无法通过UDP就自动将这些非法报文直接丢弃了,只会将合法的报文向上交付,所以向上交付了就一定能保证报文是独立的、完整的有效载荷,所以对于UDP报文,有没有把一个报文读完,作为读取方,你压根就不要自己处理,只要对方给你发(send)的时候是完整的,你收的就是完整的,所以对方一次发多少,我就一次收多少,这种一个一个由底层交上来的,一个个独立的报文,我们就称之为叫做面向数据报。
UDP的缓冲区
UDP没有真正意义上的发送缓冲区,调用sendto会直接交给内核(也就是把数据拷贝给操作系统),由内核将数据传给网络层协议进行后续的传输动作;
UDP具有接收缓冲区,但是这个接收缓冲区不能保证收到的UDP报的顺序和发送UDP报的顺序一致(不可靠),如果缓冲区满了,再到达的UDP数据就会被丢弃;
- 发送缓冲区(Send Buffer): 发送缓冲区是应用程序将数据写入套接字时使用的缓冲区。当应用程序发送数据时,数据首先被复制到发送缓冲区。然后,操作系统的网络协议栈负责将这些数据封装成UDP报文并发送到目标主机。如果发送速度快于网络传输速度,数据可能会在发送缓冲区中排队等待发送。
- 接收缓冲区(Receive Buffer): 接收缓冲区是用于存储从网络接收到的UDP数据的缓冲区。当UDP数据到达时,它首先被存储在接收缓冲区中。应用程序可以随时从接收缓冲区中读取数据。如果应用程序的读取速度慢于数据到达的速度,接收缓冲区可能会积累未读取的数据。
这些缓冲区的大小对于应用程序的性能和网络通信的效率都可能有影响。合理地设置缓冲区大小可以避免数据丢失或延迟。不过,过度增大缓冲区可能会导致内存浪费和对系统资源的不必要占用,因此需要根据具体的应用场景和需求进行调整。
UDP的socket既能读,也能写,这个概念叫做全双工。
UDP使用注意事项
我们注意到,UDP协议首部中有一个16位的最大长度,也就是说一个UDP能传输的数据最大长度是64K,2^10就是1kb(1024),2^6就是64, 1024 * 64 = 65536。(包含UDP首部)。
如果要发送超过64kb数据的报文,只能由用户进行拆片了,例如128kb的就要分成两个64kb的报文。
基于UDP的应用层协议
- NFS:网络文件系统
- TFTP:简单文件传输协议
- DHCP:动态主机配置协议
- BOOTP:启动协议(用于无盘设备启动)
- DNS:域名解析协议
当然,也包括你自己写UDP程序时自定义的应用层协议;
自定义的基于UDP的应用层协议是指在开发网络应用时,程序员可以根据特定的需求和场景定义自己的通信协议。这种自定义协议可以包括协议头、数据格式、消息类型等内容,以满足特定应用的通信需求。自定义协议的设计需要考虑通信的可靠性、实时性、数据格式等因素。在设计自定义协议时,通常需要确保协议足够简单以降低开销,并且足够灵活以适应不同的应用场景。
TCP协议
TCP全称为"传输控制协议(Transmission Control Protocol)"人如其名,要对数据的传输进行一个详细的控制
TCP协议段格式
- 源端口号(Source Port): 占用2个字节,指示发送方的应用程序使用的端口号。
- 目标端口号(Destination Port): 占用2个字节,指示接收方的应用程序期望使用的端口号。
- 序号(Sequence Number): 占用4个字节,用来表示此TCP段中的一个报文在整个数据流中的序列号。
- 确认序号(Acknowledgment Number): 占用4个字节,如果设置了ACK标志,则表示期望收到的下一个序列号。用于确认接收方已成功接收到数据。
- 4位首部长度(Data Offset): 占用4个位,表示该TCP头部有多少个32位bit(有多少个4字节) ,由于最小的TCP头部长度为20字节,因为只有4位,最大值位全1(0000->1111),即范围是0 - 15,所以TCP头部最大长度是15 * 4 = 60,因此这个字段的初始值通常是5。
- 保留(Reserved): 占用6个位,保留未来使用,目前必须设置为0。
- 6位标志位(Flags): 占用6个位,包括:
-
- URG(Urgent): 紧急指针(Urgent Pointer)是否有效。
- ACK(Acknowledgment): 确认序号是否有效。
- PSH(Push): 催促接收方应该尽快将此数据从缓冲区推送给应用层。
- RST(Reset): 重置连接。
- SYN(Synchronize): 是一个连接请求的报文,三次握手(1.SYN 2.SYN+ACK 3.ACK)。
- FIN(Finish): 是一个连接断开的请求报文,四次挥手。
- 16位窗口大小(Window Size): 占用2个字节,指发送方接收缓冲区的剩余空间大小,用于流量控制。
- 校验和(Checksum): 占用2个字节,发送端填充,CRC校验. 接收端校验不通过, 则认为数据有问题. 此处的检验和不光包含TCP首部, 也包含TCP数据部分。
- 紧急指针(Urgent Pointer): 占用2个字节,仅在URG标志被设置时才有效,标识哪部分数据是紧急数据。
- 40字节头部选项(Options): 占用可变长度,用于包含一些可选的信息,如最大段大小(MSS)、窗口扩大因子等。
- 数据(Data): 包含应用层数据,长度可变。
确认应答机制、4位首部长度
序号和确认信号及超时重传机制
16位窗口大小、流量控制
6位标志位
连接管理机制
理解TIME_WAIT状态
setsockopt函数
在网络编程中,setsockopt 函数用于设置套接字选项,它允许程序员设置一些与套接字相关的属性。setsockopt 函数的原型如下:
#include <sys/socket.h>
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
- sockfd:套接字文件描述符。
- level:选项所在的协议层,常用的有 SOL_SOCKET(通用套接字选项(常用))、IPPROTO_TCP(TCP协议选项)、IPPROTO_IP(IP协议选项)等。
- optname:需要设置的选项名。下面是一些常见的套接字选项,以及它们对应的 optname 值:
- 通用套接字选项(SOL_SOCKET):
-
- SO_REUSEADDR:重用地址选项,允许在同一端口上重用处于 TIME_WAIT 状态的套接字,即使之前绑定的套接字还没有完全关闭。
注:"重用地址选项"之所以被称为"重用地址选项",是因为这个选项允许在套接字绑定时重用已经被使用的地址。
套接字绑定时,通常会将一个IP地址和端口号绑定到套接字上,以便与其他套接字建立连接或监听。如果之前的套接字绑定到了相同的地址,那么默认情况下,新的绑定操作将会失败,因为同一个地址不能分配给多个套接字。
然而,有时候我们希望能够在同一个地址上绑定多个套接字,即使之前的套接字还没有完全关闭。这种情况下,就可以使用"重用地址选项"来启用此功能。
通过启用"重用地址选项",新的套接字可以在之前的套接字还没有完全关闭之前绑定到相同的地址。这样就可以实现地址的重用,即可以在同一个地址上绑定多个套接字。
因此,"重用地址选项"实际上是指允许在同一个地址上重用套接字的功能。这样的命名方式更加直观和易于理解。
-
- SO_REUSEPORT:允许同一端口上绑定多个套接字。
- TCP 套接字选项(IPPROTO_TCP):
-
- TCP_NODELAY:禁用 Nagle 算法,允许小数据块立即发送。
- TCP_KEEPIDLE:设置空闲连接的时间,超过该时间后将开始发送 TCP Keep-Alive 消息。
- TCP_KEEPINTVL:设置两次 Keep-Alive 之间的时间间隔。
- IP 套接字选项(IPPROTO_IP):
-
- IP_TTL:设置 IP 包的生存时间(Time To Live)。
- IP_HDRINCL:指定用户提供自己的 IP 头部。
- optval:定义一个指向存放选项值的缓冲区。
以下是一些示例,说明不同类型选项的 optval 缓冲区的用法:
- 整数类型选项:
-
- 例如,SO_RCVBUF 和 SO_SNDBUF 用于设置套接字的接收和发送缓冲区的大小。optval 缓冲区将存放一个整数,表示缓冲区的大小。
int buffer_size = 8192; // 设置缓冲区大小为 8192 字节
setsockopt(sockfd, SOL_SOCKET, SO_RCVBUF, &buffer_size, sizeof(buffer_size));
- 布尔类型选项:
-
- 例如,SO_REUSEADDR 用于允许地址重用。optval 缓冲区将存放一个布尔值,通常用整数表示,例如 1 表示启用,0 表示禁用。
int reuse = 1; // 允许地址重用
setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
- 结构体类型选项:
-
- 例如,SO_LINGER 用于设置关闭连接时的延迟时间。optval 缓冲区将存放一个结构体的内容,结构体包含有关延迟关闭的信息。
struct linger linger_opt = {1, 30}; // 启用延迟关闭,延迟时间为 30 秒
setsockopt(sockfd, SOL_SOCKET, SO_LINGER, &linger_opt, sizeof(linger_opt));
总之,optval 缓冲区用于向操作系统传递你想要设置的选项的具体值,以便自定义套接字的行为。在使用 setsockopt 函数时,你需要根据选项的文档了解具体的数据类型和取值范围,并将相应的值存储在 optval 缓冲区中。
所以optval 的值是由setsockopt函数的第三个参数的设置选项决定的。
- optlen:optval 缓冲区的长度。
注:setsockopt函数的返回值为0表示成功,-1表示失败,并设置errno来指示错误的原因。
以下是一个简单的示例,演示如何使用 setsockopt 设置套接字选项:
#include <stdio.h>
#include <stdlib.h>
#include <sys/socket.h>
int main() {
// 创建套接字
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd == -1) {
perror("socket");
exit(EXIT_FAILURE);
}
// 设置 SO_REUSEADDR 选项
int reuse = 1;
if (setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)) == -1) {
perror("setsockopt");
close(sockfd);
exit(EXIT_FAILURE);
}
// 其他操作...
// 关闭套接字
close(sockfd);
return 0;
}
在上述示例中,SO_REUSEADDR 是一个常见的套接字选项,用于允许在同一端口上快速重用处于 TIME_WAIT 状态的套接字。这是一个常见的技巧,特别是在服务器程序需要频繁重启时。请注意,实际使用时,应根据具体情况选择适当的选项。
滑动窗口
拥塞控制
虽然TCP有了滑动窗口这个大杀器,能够高效可靠的发送大量的数据,但是如果在刚开始阶段就发送大量的数据,仍然可能引发问题。
因为网络上有很多的计算机,可能当前的网络状态就已经比较拥堵,在不清楚当前网络状态下,贸然发送大量的数据,就会出现大量的丢包,并且是很有可能引起雪上加霜加重网络拥塞的严重性。
发生了网络拥塞,发送方要基本得知网络拥塞的严重程度,必须要进行网络状态的探测,需要对当前的网络情况进行衡量,所以就引入拥塞窗口这个概念。
因为网络的状态是变化的,衡量网络健康状态,即拥塞窗口大小一定也是变化的!作为主机端的你,你怎么知道网络的健康状态是什么样子的?只能通过不断的尝试和探测来得到当前网络的拥塞窗口的大小!
TCP引入慢启动机制,先发少量的数据,探探路,摸清当前的网络拥堵状态,再决定按照多大的速度传输数据;
慢启动(Slow Start): 发送方刚开始发送数据时,会以指数级别增加发送窗口的大小,从而迅速占用网络带宽。当网络开始出现拥塞时,慢启动会减缓发送速率。
之前说发送方的滑动窗口大小:滑动窗口 = 对端主机的接受能力win。
现在的发送方窗口大小:滑动窗口 = min(对端主机的接受能力win,网络的拥塞窗口),即 win_start = (应答)seq,win_end = min(ack_win,拥塞窗口)。
每次发送数据包的时候,将拥塞窗口和接收端主机反馈的窗口大小做比较,取较小的值作为实际发送的窗口;
像上面这样的拥塞窗口增长速度,是指数级别的"慢启动"只是指前期时慢,但是增长速度非常快。
为了不增长的那么快,因此不能使拥塞窗口单纯的加倍,此处引入一个叫做慢启动的阈值,当拥塞窗口超过这个阈值的时候,不再按照指数方式增长,而是按照线性方式增长。
当TCP开始启动的时候,慢启动阈值等于窗口最大值;
在每次超时重发的时候,慢启动阈值会变成原来的一半,同时拥塞窗口置回1;少量的丢包,我们仅仅是触发超时重传;大量的丢包,我们就认为网络拥塞;
当TCP通信开始后,网络吞吐量会逐渐上升;随着网络发生拥堵,吞吐量会立刻下降;
拥塞控制归根结底是TCP协议想尽可能快的把数据传输给对方,但是又要避免给网络造成太大压力的折中方案
延迟应答
如果接收数据的主机立刻返回ACK应答,这时候返回的窗口可能比较小。
假设接收端缓冲区为1M,一次收到了500K的数据,如果立刻应答,返回的窗口就是500K;
但实际上可能处理端处理的速度很快,10ms之内上层就把500K数据从缓冲区读取走了;
在这种情况下,接收端处理还远没有达到自己的极限,即使窗口再放大一些,也能处理过来;
如果接收端稍微等一会再应答,比如等待200ms再应答,那么这个时候返回的窗口大小就是1M;
一定要记得,窗口越大,网络吞吐量就越大,传输效率就越高,我们的目标是在保证网络不拥塞的情况下尽量提高传输效率。那么所有的包都可以延迟应答么?肯定也不是;
数量限制:每隔N个包就应答一次;
时间限制:超过最大延迟时间就应答一次;
具体的数量和超时时间,依操作系统不同也有差异,一般N取2超时时间取200ms;
TCP小结
为什么TCP这么复杂?因为要保证可靠性,同时又尽可能的提高性能。
可靠性:
- 校验和
- 序列号(按序到达)
- 确认应答
- 超时重发
- 连接管理
- 流量控制
- 拥塞控制
提高性能:
- 滑动窗口
- 快速重传
- 延迟应答
- 捎带应答
其他:
- 定时器(超时重传定时器,保活定时器,TIME_WAIT定时器等)
面相字节流
创建一个TCP的socket,同时在内核中创建一个 发送缓冲区 和一个 接收缓冲区;
- 调用write时,数据会先写入发送缓冲区中;
- 如果发送的字节数太长,会被拆分成多个TCP的数据包发出;
- 如果发送的字节数太短,就会先在缓冲区里等待,等到缓冲区长度差不多了,或者其他合适的时机发送出去;
- 接收数据的时候,数据也是从网卡驱动程序到达内核的接收缓冲区;
- 然后应用程序可以调用read从接收缓冲区拿数据;
- 另一方面,TCP的一个连接,既有发送缓冲区,也有接收缓冲区,那么对于这一个连接,既可以读数据,也可以写数据这个概念叫做 全双工。
由于缓冲区的存在,TCP程序的读和写不需要一一匹配,例如:
- 写100个字节数据时可以调用一次write写100个字节,也可以调用100次write,每次写一个字节;
- 读100个字节数据时,也完全不需要考虑写的时候是怎么写的,既可以一次read 100个字节,也可以一次 read一个字节,重复100次;
粘包问题
- 首先要明确,粘包问题中的"包",是指的应用层的数据包。
- 在TCP的协议头中,没有如同UDP一样的"报文长度"这样的字段,但是有一个序号这样的字段。
- 站在传输层的角度,TCP是一个一个报文过来的,按照序号排好序放在缓冲区中。
- 站在应用层的角度,看到的只是一串连续的字节数据。
- 那么应用程序看到了这么一连串的字节数据,就不知道从哪个部分开始到哪个部分是一个完整的应用层数据包
那么如何避免粘包问题呢?归根结底就是一句话,明确两个包之间的边界。
粘包问题通常在编写TCP客户端和服务器端时通过协议来解决。TCP是一种面向流的协议,它不保证数据的边界,因此在传输数据时,连续的数据流可能被看作一个或多个数据包。这就是粘包问题的来源。
为了解决粘包问题,通常在应用层采用一些协议的技术,如消息长度前缀、消息边界标识等。这些技术在通信双方都遵循相同的协议规则的情况下,可以帮助接收方准确地分割和解析收到的数据,从而避免粘包问题的发生。因此,在编写TCP客户端和服务器端时,设计合适的协议是解决粘包问题的一项关键任务。对于变长的包, 还可以在包和包之间使用明确的分隔符(应用层协议, 是程序猿自己来定的, 只要保证分隔符不和正文冲突即可),列如我们之前文章里编写TCP的客户端和服务器,通过协议不断判断是否读取到一个完整的报文,然后对报文进行解析等。
对于UDP协议来说,是否也存在"粘包问题"呢?
对于UDP,如果还没有对上层交付数据,UDP的报文长度仍然在,同时,UDP是一个一个把数据交付给应用层就有很明确的数据边界。例如我们在编写UDP的客户端和服务器时,就不用判断是否读取到一个完整报文和解析,站在应用层的角度,使用UDP的时候,要么收到完整的UDP报文,要么不收,不会出现"半个"的情况。
TCP异常情况
进程终止:进程终止会释放文件描述符,仍然可以发送FIN进行四次挥手,和正常关闭没有什么区别。
机器重启:和进程终止的情况相同。比如我们在打开了许多的软件,关机的时候并没有把这些软件一个一个的去关闭,而是直接关闭或者重启电脑,这时候就会有一个弹窗告诉你"您有进程为终止,请问是否立即终止",这个过程就是在对数据进行保存关闭连接进行四次挥手。
机器掉电/网线断开:接收端认为连接还在,一旦接收端有写入操作,接收端发现连接已经不在了,就会进行reset(连接重置),期间询问对方是否还在,即使没有写入操作,如果对方不在,TCP自己也内置了一个保活定时器,也会把连接释放,另外,应用层的某些协议,也有一些这样的检测机制,例如HTTP长连接中,也会定期检测对方的状态例如QQ,在QQ断线之后也会定期尝试重新连接。
用UDP实现可靠传输(经典面试题)
我们可以通过参考TCP的可靠性机制,在应用层实现类似的逻辑:
例如:
引入序列号,保证数据顺序;
引入确认应答,确保对端收到了数据;
引入超时重传,如果隔一段时间没有应答,就重发数据;
.....
简单来说就是把TCP的可靠性机制加到UDP身上。
理解listen的第二个参数
基于之前封装的Socket 实现以下测试代码
对于服务器,listen的第二个参数设置为1,并且不调用accept