netstat命令->查看网络状态
- n 拒绝显示别名,能显示数字的全部转化成数字
- l 仅列出有在 Listen (监听) 的服務状态
- p 显示建立相关链接的程序名
- t (tcp)仅显示tcp相关选项
- u (udp)仅显示udp相关选项
- a (all)显示所有选项,默认不显示LISTEN相关
pidof命令->查看进程id
- pidof + 进程名 -> 查看进程id
UDP协议
UDP协议端格式
1. 如何分离(封装)
- 固定长度的报头,就能将报头和有效载荷分离
2.如何交付
- 根据报头中的16位端口号,进行向上交付(进程bind了端口号)
udp是如何正确的提取整个完整的报头
- 固定长度的报头 -> 16位udp长度,这也解释了在应用层写端口号的时候,类型是uint16_t类型的
3.理解udp报文本身
4. sendto/recvfrom/write/read/recv/send ...io类接口
- 这些函数本质都是拷贝函数
5.udp 全双工 && 半双工
- udp是全双工的
- 全双工: 一边读,一边写(可以同时发生)
- 半双工: 只能一边读,或者一边写(不可以同时发生)
TCP协议
TCP协议端格式
- 4为首部长度虽然是4个bit位,但是单位是字节
- 0000->1111 => 0 -> 15,即范围是0->60字节
- 报头的范围: [20,60]
- x * 4 = 20 推出 x = 5->0101
- x * 4 = 60 推出 x = 15->1111
1. TCP是如何交付的
- 根据报头中的16位端口号,进行向上交付(进程bind了端口号)
2. TCP是如何解包的
-
先提取20字节
-
根据标准报头,提取4位首部长度 * 4 = 20 - done
-
读取[提取4位首部长度 * 4 - 20]字节数据,选项
-
读完了报头,剩下的都是有效载荷
3.理解可靠性
- 在网络中不存在100%可靠的协议,无论那台主机都无法保证自己作为最新发送数据的一方
发送出去的数据都能被对方收到 - 但是在局部上能做到100%可靠,只有有匹配应答,就能保证刚发出去的消息,对方收到了
- TCP协议的确认机制: 只要一个报文收到了对应的应答,就能保证我发出的数据对方收到了
4.序号和确认序号
- client一次可能向服务器发送多个报头,就会有一个问题,发送的顺序,不一定是接收顺序
- 序号和确认序号,就是解决这个问题的
序号和确认序号:
- 将请求和应答进行一一对应
- 确认序号,表示的含义:确认序号之前的数据已经全部收到的
- 允许部分确认丢失,或者不给应答
- 为什么要有两个字段数字
- 任何通信的一方,工作方式都是全双工的,在发送确认的时候,也可能携带新的数据
- 1000 2000 3000 -> 2000 1000 3000,发送和接收的顺序不一定一样,因为任何一方都会收到报文,但报文中会携带序号(可以排序)
TCP的接收缓冲区和发送缓冲区
- tcp有发送和接收缓冲区,当我们有client和server,我们就要有两对接收和发送缓冲区
TCP的6个标记位
- TCP的6个标记位都是标记报文类型的
- SYN: 该报文是一个链接请求报文
- FIN: 该报文是一个断开链接请求的报文
- ACK: 确认应答标志位,
- 凡是该报文具有应答特征,该标志位都会被设置成1,大部分网络报文ACK都是被设置为1的
除了一个链接请求报文
- 凡是该报文具有应答特征,该标志位都会被设置成1,大部分网络报文ACK都是被设置为1的
- RST: reset,连接重置
- PSH: PUSH,督促对方尽快将数据进行向上交付
- URG: 紧急标志位,16位紧急指针
1.理解TCP的连接
- 因为有大量的client将来可能会链接server,所以server端一定会存在大量的连接
- OS会通过先描述,再组织的方式管理这些连接
- 所谓的连接:本质其实就是内核的一种数据结构类型,建立连接成功的时候,就是在内存中创建对应的连接对象,再对多个连接对象进行某种数据结构的组织
- 维护连接是有成本的(内存+cpu)
2. 理解TCP的三次握手
- 这三次握手不一定都要保证成功
为什么需要三次握手
- 站在client端,他认为只要发送了SYN给server,就建立了连接
- 站在server端,他认为只要发送了SYN给client,就建立了连接
- 但实际上需要收到对方的应答,才算是建立的连接,
- 所以1次肯定不行,偶数次也不行,3次就刚好可以(客户端和服务端都会建立链接)
- 这3次握手也能验证全双工,
3. 理解四次挥手
- 如果我们发生服务器中具有大量的close_wait状态的连接
- 有可能是因为应用层写的时候有bug(没有关闭对应的连接sockfd)
- 为什么会有TIME_WAIT状态,而不是直接就是CLOSED状态
- 虽然4次挥手已经完成,但是主动断开连接的一方要维持一定的TIME_WAIT状态,
- 在该状态下,连接其实已经结束了,但是地址信息ip和port依旧是被占用的,等待上面其他的发送结束(等待时间也是不确定的)
setsockopt函数
- 4次挥手结束后TIME_WAIT到CLOSED状态的时间太长,该函数会缩短这个时间
确认应答(ACK)机制
TCP将每个字节的数据都进行了编号. 即为序列号
- 每一个ACK都带有对应的确认序列号, 意思是告诉发送者, 我已经收到了哪些数据; 下一次你从哪里开始发.
超时重传机制
- 主机A发送数据给B之后, 可能因为网络拥堵等原因, 数据无法到达主机B;
- 如果主机A在一个特定时间间隔内没有收到B发来的确认应答, 就会进行重发
但是
,
主机
A
未收到
B
发来的确认应答
,
也可能是因为
ACK
丢失了
- 主机B会收到很多重复数据. 那么TCP协议通过序列号就能够识别出那些包是重复的包, 并且把重复的丢弃掉.达到去重的效果
超时时间的影响
- 这个时间的长短, 随着网络环境的不同, 是有差异的.
- 如果超时时间设的太长, 会影响整体的重传效率;
- 如果超时时间设的太短, 有可能会频繁发送重复的包;
解决方案
TCP为了保证无论在任何环境下都能比较高性能的通信, 因此会动态计算这个最大超时时间
- Linux中(BSD Unix和Windows也是如此), 超时以500ms为一个单位进行控制, 每次判定超时重发的超时间, 他都是500ms的整数倍.
- 如果重发一次之后, 仍然得不到应答, 等待 2*500ms 后再进行重传.
- 如果仍然得不到应答, 等待 4*500ms 进行重传. 依次类推, 以指数形式递增.
- 累计到一定的重传次数, TCP认为网络或者对端主机出现异常, 强制关闭连接
连接管理机制
滑动窗口
确认应答策略 ,对每一个发送的数据段 , 都要给一个 ACK 确认应答 . 收到 ACK 后再发送下一个数据段 .
这样做有一个比较大的缺点, 就是 性能较差 . 尤其是数据往返的 时间较长 的时候
- 窗口大小指的是无需等待确认应答而可以继续发送数据的最大值.
上图的窗口大小就是4000个字节(四个段). - 发送前四个段的时候, 不需要等待任何ACK, 直接发送;
- 滑动窗口: 在自己的发送缓冲区中,属于自己的发送缓冲区中的一部分
- 一边想要给对方推更多的数据,另一边又想要对方来得及接收
- 滑动窗口的上限 == 对方的接收能力
- 本质: 让sender方,可以一次性发送对方接收数据的上限
滑动窗口的进一步理解
-
滑动窗口的本质: 就是指针或下标
-
更新:win_start = 收到的应答报文中的确认序号,
win_end=win_start + 收到报文中的窗口大小 - 滑动窗口不一定都是右移, 也可以为0
- 如果收到的不是开始的报文的应答,而是中间的,不影响
- 滑动窗口如果一直右移滑动,是不存在越界问题的
快重传 vs 超时重传
- 快重传是有条件的,
- 这两个不是对立的,而是相互协作的
拥塞控制
- 虽然TCP有了滑动窗口这个大杀器, 能够高效可靠的发送大量的数据.
但是如果在刚开始阶段就发送大量的数据, 仍然可能引发问题. - 因为网络上有很多的计算机, 可能当前的网络状态就已经比较拥堵.
在不清楚当前网络状态下, 贸然发送大量的数据, 是很有可能引起雪上加霜的. - TCP引入 慢启动 机制, 先发少量的数据, 探探路, 摸清当前的网络拥堵状态, 再决定按照多大的速度传输数据;
- 拥塞窗口: 单台主机一次向网络中发送大量数据时,可能会引发网络拥塞的上限值
- 滑动窗口的大小 = min(拥塞窗口,对方窗口大小(接收能力))
拥塞窗口变化大小
- 当TCP开始启动的时候, 慢启动阈值等于窗口最大值;
- 在每次超时重发的时候, 慢启动阈值会变成原来的一半, 同时拥塞窗口置回1
拥塞窗口变化原因
- 它是指数级别增长,特点: 前期慢,后期快
- 在网络拥塞时
- 前期要让网络有一个缓一缓的机会
- 中后期,网络恢复之后,需要尽快恢复通信 -> 避免影响通信效率
拥塞控制的总结
- 少量的丢包, 我们仅仅是触发超时重传; 大量的丢包, 我们就认为网络拥塞;
- 当TCP通信开始后, 网络吞吐量会逐渐上升;
- 随着网络发生拥堵, 吞吐量会立刻下降;
- 拥塞控制, 归根结底是TCP协议想尽可能快的把数据传输给对方,
但是又要避免给网络造成太大压力的折中方案.
延迟应答
- 假设接收端缓冲区为1M. 一次收到了500K的数据; 如果立刻应答, 返回的窗口就是500K;
- 但实际上可能处理端处理的速度很快, 10ms之内就把500K数据从缓冲区消费掉了;
- 在这种情况下, 接收端处理还远没有达到自己的极限, 即使窗口再放大一些, 也能处理过来;
- 如果接收端稍微等一会再应答, 比如等待200ms再应答, 那么这个时候返回的窗口大小就是1M;
延迟应答的时间
- 数量限制: 每隔N个包就应答一次;
- 时间限制: 超过最大延迟时间就应答一次
捎带应答
- ACK可以和服务器回应的 信息 一起回给客户端
面向字节流
创建一个
TCP
的
socket,
同时在内核中创建一个
发送缓冲区
和一个
接收缓冲区;
-
调用 write 时, 数据会先写入发送缓冲区中;
-
如果发送的字节数太长, 会被拆分成多个TCP的数据包发出;
-
如果发送的字节数太短, 就会先在缓冲区里等待, 等到缓冲区长度差不多了, 或者其他合适的时机发送出去;
-
接收数据的时候, 数据也是从网卡驱动程序到达内核的接收缓冲区;
然后应用程序可以调用 read 从接收缓冲区拿数据; -
另一方面, TCP的一个连接, 既有发送缓冲区, 也有接收缓冲区, 那么对于这一个连接, 既可以读数据, 也可以写数据. 这个概念叫做 全双工
由于缓冲区的存在, TCP程序的读和写不需要一一匹配, 例如:
-
写100个字节数据时, 可以调用一次write写100个字节, 也可以调用100次write, 每次写一个字节;
-
读100个字节数据时, 也完全不需要考虑写的时候是怎么写的, 既可以一次read 100个字节, 也可以一次read一个字节, 重复100次;
粘包问题
-
首先要明确, 粘包问题中的 "包" , 是指的应用层的数据包
- 站在应用层的角度, 看到的只是一串连续的字节数据
- 明确两个包之间的边界,就可以解决这个问题
TCP异常情况
-
进程终止: 进程终止会释放文件描述符 , 仍然可以发送 FIN. 和正常关闭没有什么区别 .机器重启 : 和进程终止的情况相同 .
-
机器掉电/网线断开 : 接收端认为连接还在 , 一旦接收端有写入操作 , 接收端发现连接已经不在了
就会进行 reset .即使没有写入操作, TCP自己也内置了一个保活定时器 ,
会定期询问对方是否还在 . 如果对方不在 , 也会把连接释放 .
TCP小结
要保证可靠性, 同时又尽可能的提高性能
可靠性:
-
1.校验和,2.序列号(按序到达) 3.确认应答 4.超时重发 5.连接管理 6流量控制 7.拥塞控制
提高性能:
-
1.滑动窗口 2.快速重传 3.延迟应答 4.捎带应答
TCP/UDP对比
- TCP用于可靠传输的情况, 应用于文件传输, 重要状态更新等场景;
-
UDP用于对高速传输和实时性要求较高的通信领域, 例如, 早期的QQ, 视频传输等. 另外UDP可以用于广播
-
TCP不 一定就优于 UDP,他们 之间的优点和缺点 , 不能简单 , 绝对的进行比较,需要看具体场景
理解 listen 的第二个参数
-
首先accpet函数是从已经建立好连接中,获取对应的连接,它是不需要参与三次握手的
- 但如果上层来不及调用accept函数,并且对端还来的大量的连接,那怎么处理这些连接?
- 服务器本身要维护一个连接队列,不能没有,不能太长
- 这个连接队列就和listen的第二个参数有关
- 这个队列的长度 = listen的第二个参数 : ESTABLISHED -> SYN_RECV
演示
Sock.hpp
#pragma once
#include <iostream>
#include <string>
#include <cstring>
#include <cerrno>
#include <cassert>
#include <unistd.h>
#include <memory>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <ctype.h>
class Sock
{
private:
// listen的第二个参数,意义:底层全连接队列的长度 = listen的第二个参数+1
const static int gbacklog = 1;
public:
Sock() {}
int Socket()
{
int listensock = socket(AF_INET, SOCK_STREAM, 0);
if (listensock < 0)
{
exit(2);
}
int opt = 1;
setsockopt(listensock, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));
return listensock;
}
void Bind(int sock, uint16_t port, std::string ip = "0.0.0.0")
{
struct sockaddr_in local;
memset(&local, 0, sizeof local);
local.sin_family = AF_INET;
local.sin_port = htons(port);
inet_pton(AF_INET, ip.c_str(), &local.sin_addr);
if (bind(sock, (struct sockaddr *)&local, sizeof(local)) < 0)
{
exit(3);
}
}
void Listen(int sock)
{
if (listen(sock, gbacklog) < 0)
{
exit(4);
}
}
// 一般经验
// const std::string &: 输入型参数
// std::string *: 输出型参数
// std::string &: 输入输出型参数
int Accept(int listensock, std::string *ip, uint16_t *port)
{
struct sockaddr_in src;
socklen_t len = sizeof(src);
int servicesock = accept(listensock, (struct sockaddr *)&src, &len);
if (servicesock < 0)
{
return -1;
}
if(port) *port = ntohs(src.sin_port);
if(ip) *ip = inet_ntoa(src.sin_addr);
return servicesock;
}
bool Connect(int sock, const std::string &server_ip, const uint16_t &server_port)
{
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(server_port);
server.sin_addr.s_addr = inet_addr(server_ip.c_str());
if(connect(sock, (struct sockaddr*)&server, sizeof(server)) == 0) return true;
else return false;
}
~Sock() {}
};
main.cc
#include "Sock.hpp"
int main()
{
Sock sock;
int listensock = sock.Socket();
sock.Bind(listensock, 8080);
sock.Listen(listensock);
while(true)
{
sleep(1);
}
}
Makefile
TcpServer:main.cc
g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
rm -f TcpServer