WebServer 01

63 阅读30分钟

WebServer 01

解决了几个问题: 1.如何部署qinguoyi/TinyWebServer项目?(引出问题2)

2.如何使用gdb进行基础的调试?

3.为什么main函数的参数是int argc和char* argv[]?

4.什么叫日志写入方式为同步/异步?(引出问题5)

5.为什么创建线程/连接会有开销?(引出问题6)

6.线程池一定比直接创建线程更快吗?它的真正优势是什么?

7.两台没有物理线缆连接的计算机之间是如何进行数据传输的?(引出问题8、问题11)

8.这个世界上每一台计算机都有自己的IP地址吗?为什么好像很多都是以192.168开头的?(引出问题9、10)

9.什么是公网IP?什么是私网IP?

10.路由器是否也有自己独立的IP?它与上层路由器是通过什么连接的?设备与路由器是通过什么连接的?

11.什么是TCP?它的头格式有哪些?

12.TCP头格式的6个控制块分别有什么含义?

13.什么是TCP三次握手?这三次握手是如何进行的?

克隆经典TinyWebServer项目:

git clone https://gitclone.com/github.com/qinguoyi/TinyWebServer.git

由于网络问题,这里我使用了镜像站

先尝试编译运行一下,看看效果

安装MySQL开发库:

# 安装MySQL服务器和开发库
sudo apt update
sudo apt install mysql-server libmysqlclient-dev
​
# 启动MySQL服务
sudo systemctl start mysql
sudo systemctl enable mysql

检查MySQL状态:

sudo systemctl status mysql

应该显示绿色的active(running)

登录MySQL:

sudo mysql -u root -p

按照README.md的指引创建数据库:

-- 创建数据库
CREATE DATABASE mydb;
​
-- 使用数据库
USE mydb;
​
-- 创建user表
CREATE TABLE user(
    username char(50) NULL,
    passwd char(50) NULL
)ENGINE=InnoDB;
​
-- 添加测试数据
INSERT INTO user(username, passwd) VALUES('name', 'passwd');
​
-- 确认数据插入成功
SELECT * FROM user;
​
-- 退出
EXIT;

mysql的基础语法还是不难理解的,这里不做解释

找到main.cpp中的下面这三行:

string user = "root";
string passwd = "";        // 如果你的MySQL root有密码,改成实际密码
string databasename = "mydb";

我没有设置密码,所以passwd设置为空字符串

接着,运行构建脚本:

# 给构建脚本执行权限
chmod +x build.sh
​
# 运行构建脚本
sh ./build.sh

运行服务器:

./server

出现了问题:运行服务器后立即返回了,即服务器启动之后立刻退出了,说明初始化出现了问题

先看看数据库是否正常连接:

sudo mysql -u root mydb

能正常进入mysql,那就说明不是数据库连接的问题

退出mysql后,再次运行服务器,发现问题仍然没有解决

检查一下程序是否真的编译成功:

# 检查server文件是否存在且可执行
ls -la server
file server
​
# 检查文件大小,确保不是空文件
ls -lh server

7ec34dfb77cb16d74352dd094b365e12.png

这说明程序编译成功,文件正常

检查是否有缺失的库:

# 检查是否有缺失的库
ldd ./server | grep "not found"

检查MySQL库链接:

# 检查是否链接了正确的MySQL库
ldd ./server | grep mysql
​
# 检查MySQL库路径
whereis libmysqlclient

f28c39de2dbae1557f5d929fc34f8967.png

可见库依赖正常

使用gdb调试:

gdb ./server
# 在gdb中设置断点并运行
(gdb) break main
(gdb) run
(gdb) where
(gdb) continue

1279cc96a6ee17e6f0e4526a51fd67f1.png

这说明程序能够正确进入main函数,但立即以退出码01退出

为了方便,我们加一行调试信息在main函数的开头:

int main(int argc, char* argv[]) {
    std::cout << "main function start" << std::endl;

再次运行服务器:

./server

发现只输出main function start,然后立即退出。这说明问题出在main函数内部的某个初始化步骤上

我们继续使用gdb进行单步调试:

gdb ./server
(gdb) break main
(gdb) run
(gdb) next # 接着一直按回车,看到哪一行退出

结果:

f629bd21df028fe2c78d05dbc0a08bf2.png

附上main.cpp的代码:

#include "config.h"int main(int argc, char *argv[])
{
    std::cout<<"main function start"<<std::endl;
    //需要修改的数据库信息,登录名,密码,库名
    string user = "root";
    string passwd = "";
    string databasename = "mydb";
​
    //命令行解析
    Config config;
    config.parse_arg(argc, argv);
​
    WebServer server;
​
    //初始化
    server.init(config.PORT, user, passwd, databasename, config.LOGWrite, 
                config.OPT_LINGER, config.TRIGMode,  config.sql_num,  config.thread_num, 
                config.close_log, config.actor_model);
    
​
    //日志
    server.log_write();
​
    //数据库
    server.sql_pool();
​
    //线程池
    server.thread_pool();
​
    //触发模式
    server.trig_mode();
​
    //监听
    server.eventListen();
​
    //运行
    server.eventLoop();
​
    std::cout<<"main function end"<<std::endl;
    return 0;
}

显然,运行到server.sql_pool()就退出了,说明问题出现在数据库连接池的初始化

检查MySQL的认证插件:

SELECT user, host, plugin FROM mysql.user WHERE user = 'root';

fe0087fe72f3adf763713bfd7d7200fe.png

可以看到root用户使用的是auth_socket插件,这意味着它只能通过Unix Socket认证,不能通过密码认证

改为使用mysql_native_password插件并设置空密码:

ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY '';

验证修改:

SELECT user, host, plugin FROM mysql.user WHERE user = 'root';

80941848537d95f162844c3cb155c9f7.png

修改成功

清理之前的编译结果:(如果不清理,就会显示up to date)

rm -f server

重新编译:

./build.sh

运行服务器:

./server

一直卡着,说明服务器已经在持续运行监听连接了

973aa140af52a5c928ff34c7107f159e.png

可以测试一下具体页面:

curl http://localhost:9006/index.html

(需要先安装curl,sudo apt update+sudo apt install curl即可)

88009b71a4a46df1f1cb5ad23dc1a5a8.png

或者可以直接打开浏览器访问:

http://localhost:9006

e1307e652f0781f9e9378fbee2380ba4.png

说明数据库连接成功、服务器正常运行、HTML页面正确显示

解释有关调试的内容: 1.打断点可以让程序在指定位置暂停执行,以便我们查看变量的值、查看调用栈、单步执行代码

2.打断点的方法:

# 1. 在指定函数打断点
(gdb) break function_name
​
# 2. 在指定文件的行号打断点  
(gdb) break filename.cpp:15# 3. 在指定类的成员函数打断点
(gdb) break MyClass::myMethod# 4. 条件断点:当x==10时才中断
(gdb) break main.cpp:23 if x == 10# 5. 临时断点:命中一次后自动删除
(gdb) tbreak main.cpp:30

3.终端gdb并不知道程序员在vscode上打的断点

4.查看所有断点:

(gdb) info breakpoints
# 或简写
(gdb) i b

删除断点:

(gdb) delete 1    # 删除1号断点
(gdb) delete      # 删除所有断点

关于auth_socket的认证机制

auth_socket不验证密码,而是验证操作系统用户与MySQL用户是否匹配

这虽然相对安全,但不利于应用程序连接

在做上一个线程池项目之前,我用Go实现了一个简易的聊天室(支持在线私聊、在线群聊、离线留言、查看在线用户列表、上下线提示功能)(github链接:github.com/KevinJoseph…),因此对网络编程也算有一点点基本的了解。编译运行此项目后,感觉很简洁,没有业务逻辑,整体思路是用户点击 → 登录验证 → 显示简单页面。这是因为这个项目主要是用于网络编程入门,重点展示网络编程基础(epoll,socket api的使用)、协议解析、并发处理(线程池)、数据库连接等,这为我们后续进行扩展提供了广阔的空间

接着我们看看main函数里每个函数的功能与实现

int main(int argc, char *argv[])
{
    std::cout<<"main function start"<<std::endl;
    //需要修改的数据库信息,登录名,密码,库名
    string user = "root";
    string passwd = "";
    string databasename = "mydb";
​
    //命令行解析
    Config config;
    config.parse_arg(argc, argv);
​
    WebServer server;
​
    //初始化
    server.init(config.PORT, user, passwd, databasename, config.LOGWrite, 
                config.OPT_LINGER, config.TRIGMode,  config.sql_num,  config.thread_num, 
                config.close_log, config.actor_model);
    
​
    //日志
    server.log_write();
​
    //数据库
    server.sql_pool();
​
    //线程池
    server.thread_pool();
​
    //触发模式
    server.trig_mode();
​
    //监听
    server.eventListen();
​
    //运行
    server.eventLoop();
​
    std::cout<<"main function end"<<std::endl;
    return 0;
}

1️⃣ 为什么有时候main函数有参数,有时候没有?

int main(int argc, char *argv[])

argc:参数个数(argument count)

argv:参数值数组(argument vector)

这样我们可以在启动时再接受配置,不必硬编码在代码里

其实之前用过的一些命令也类似:

gcc -o program main.c          # gcc接受参数
ls -l /home                    # ls接受参数  
git commit -m "message"        # git接受参数

举一个直观的例子:

创建一个main.cpp

#include <iostream>int main(int argc, char *argv[]) {
    std::cout << "参数个数: " << argc << std::endl;
    
    for (int i = 0; i < argc; i++) {
        std::cout << "argv[" << i << "] = " << argv[i] << std::endl;
    }
    
    return 0;
}

编译、运行

# 编译
g++ -std=c++20 -O1 main.cpp -o test# 运行
./test

f4e916cf1ce27891ccfa0aa3606317ca.png

传入命令行参数:

./test hello world cpp

6232489540c94a3e674361a4fc939877.png

这就好比每个类都有自己的this指针一样,每个main函数都有自己的argc和argv,你可以不用它,不代表它没有

从我们输入命令到程序开始运行,大概要经历这么一个流程:

用户输入命令 -> Shell解析 -> 创建进程 -> 调用main(argc,argv) -> 程序运行

命令行参数在程序开始运行之前就已经准备好了,这是它和std::cin以及std::getline等的显著区别。命令行参数是启动前注入,直接从内存读取参数,不需要I/O操作。而后面两位是运行时输入,会阻塞,等待I/O

除此之外还有第三个隐藏参数:

int main(int argc, char *argv[], char *envp[]) {
    // envp 包含环境变量
    for (int i = 0; envp[i] != NULL; i++) {
        cout << envp[i] << endl;  // 比如 PATH=/usr/bin, HOME=/home/user
    }
}

不过一般使用getenv()获取环境变量

2️⃣ 命令行解析

    //命令行解析
    Config config;
    config.parse_arg(argc, argv);

Config类的声明:

class Config
{
public:
    Config();
    ~Config(){};
​
    void parse_arg(int argc, char*argv[]);
​
    //端口号
    int PORT;
​
    //日志写入方式
    int LOGWrite;
​
    //触发组合模式
    int TRIGMode;
​
    //listenfd触发模式
    int LISTENTrigmode;
​
    //connfd触发模式
    int CONNTrigmode;
​
    //优雅关闭链接
    int OPT_LINGER;
​
    //数据库连接池数量
    int sql_num;
​
    //线程池内的线程数量
    int thread_num;
​
    //是否关闭日志
    int close_log;
​
    //并发模型选择
    int actor_model;
};

声明了一个返回值为void的函数parse_arg,然后定义了一些变量,看看这个类的定义:

#include "config.h"
​
Config::Config(){
    //端口号,默认9006
    PORT = 9006;
​
    //日志写入方式,默认同步
    LOGWrite = 0;
​
    //触发组合模式,默认listenfd LT + connfd LT
    TRIGMode = 0;
​
    //listenfd触发模式,默认LT
    LISTENTrigmode = 0;
​
    //connfd触发模式,默认LT
    CONNTrigmode = 0;
​
    //优雅关闭链接,默认不使用
    OPT_LINGER = 0;
​
    //数据库连接池数量,默认8
    sql_num = 8;
​
    //线程池内的线程数量,默认8
    thread_num = 8;
​
    //关闭日志,默认不关闭
    close_log = 0;
​
    //并发模型,默认是proactor
    actor_model = 0;
}
​
void Config::parse_arg(int argc, char*argv[]){
    int opt;
    const char *str = "p:l:m:o:s:t:c:a:";
    while ((opt = getopt(argc, argv, str)) != -1)
    {
        switch (opt)
        {
        case 'p':
        {
            PORT = atoi(optarg);
            break;
        }
        case 'l':
        {
            LOGWrite = atoi(optarg);
            break;
        }
        case 'm':
        {
            TRIGMode = atoi(optarg);
            break;
        }
        case 'o':
        {
            OPT_LINGER = atoi(optarg);
            break;
        }
        case 's':
        {
            sql_num = atoi(optarg);
            break;
        }
        case 't':
        {
            thread_num = atoi(optarg);
            break;
        }
        case 'c':
        {
            close_log = atoi(optarg);
            break;
        }
        case 'a':
        {
            actor_model = atoi(optarg);
            break;
        }
        default:
            break;
        }
    }
}

日志:

日志就是程序的日记,记录程序运行期间发生的各种事件、状态、错误等信息

平时写算法题进行临时调试的时候,比如写动态规划题目,打印一下dp数组看看状态转移是否正确,这也叫日志。只不过这个WebServer的日志更加结构化、高性能

日志写入方式为同步,即:每次写日志都直接写入文件,简单可靠,但性能较低

异步则是:日志先放入缓冲区,由后台线程批量写入,性能高,但可能丢失部分日志,原因有三个:1.缓冲区有限,可能旧日志被覆盖;2.内存数据容易丢失,程序崩溃时日志就没了;3.资源竞争,多个线程同时写文件容易出错。为了解决这个问题,这里使用阻塞队列,这样不仅规避了日志丢失问题,还规避了阻塞问题

想要写日志的线程只做push操作,交由其它线程真正进行写文件的操作。因为写文件操作会阻塞,所以这样也避免了想要写日志的线程阻塞在写文件操作,耽误了后面可能的网络处理

似乎有点像上一个项目:线程池中的enqueue函数?enqueue函数将任务放入队列中,给线程池中的线程处理

连接池:可类比线程池理解。每个连接池内部都有待使用的连接,这样就避免了引入频繁创建或销毁连接的开销。初始化连接池时创建多个真实的连接,需要连接的时候就调用拿连接的函数,不需要的时候就调用放回的函数。在这个过程中连接都是保持可用的状态,不存在创建或销毁的开销

❓ 追问:创建或销毁线程、连接的开销源于哪里?

答:创建线程的开销主要来自:

1.内存分配

每个线程都需要: 线程栈空间:用于局部变量、函数调用栈,需要分配物理内存页

线程控制块(TCB):操作系统管理线程的数据结构,包含状态、优先级、寄存器值等

线程局部存储(TLS):每个线程独有的全局变量

2.系统调用(上下文切换成本)

创建线程涉及:从用户态切换为内核态、内核分配资源并设置数据结构、调度器将新线程加入就绪队列、切换回用户态

这个过程需要保存/恢复CPU寄存器,更新内存管理单元(MMU),刷新CPU缓存

3.新线程启动时,需要重新加载指令和数据到缓存

比喻理解缓存:CPU缓存如同你桌面的空间,内存如同你的书架,硬盘如同图书馆。从桌面拿书只是一瞬间的事情,从书架拿书也就稍慢一点,而去图书馆借书则就很慢了

假设我们在频繁调用函数void func1(User *user)

那么在缓存热的状态下(理想情况),CPU L1缓存存有func1指令、User结构体以及相关变量,L2缓存有更多的相关代码和数据,TLB缓存有这些内存地址的映射关系。相当于此时所有需要的东西都在桌面上,直接拿即可

而当我们创建新的线程时,新线程的代码和数据都不在缓存中。CPU发现指令不在缓存、数据不在缓存,就要去内存找,慢。CPU发现地址映射不在TLB,就要去查页表,更慢

❓进一步追问:池化不也避免不了要创建线程吗?

答:事实上线程池也是直接创建线程,但我们从数学角度分析一下其优势所在

设C=创建单个线程的时间

T=任务执行时间

N=线程数

R=请求总数

那么直接创建线程的总时间约为:

T1=R(C+T)T_1=R(C+T)

线程池的总时间约为:

T2=NC+RTT_2=NC+RT

两式做差,可得:

T1T2=C(RN)T_1-T_2=C(R-N)

显然,当请求数R大于线程数N时,线程池是有优势的。反之,如果请求数很少,创建线程又过多,那么线程池可能会更慢

可见,线程池在高并发场景下优势是更明显的。而且,虽然创建同样数量的线程时间是一样的,但是线程池的响应速度快,因为早就已经有线程做好工作准备了

此外,线程池还有效规避了内存碎片。什么是内存碎片?创建线程需要经过分配->使用->销毁的过程,我们假设分配的内存空间为8MB,并且将内存比作停车场:

一开始所有车位都是空的,假设有8个连续空位

频繁分配和释放后,很可能出现有空位但是空位不连续的情况

那如果现在想要停一辆大货车(需要占用2个连续的空位),那就没办法实现了

回到程序里看,就是当你需要分配16MB或者32MB的连续内存,就有可能失败了,事实上并不是因为空间不够,只是内存碎片太多,没有连续且足够的空间。内存分配器的工作负担就加重了,它需要搜索合适的空闲块(时间变长),可能需要进行内存压缩,会更频繁地触发垃圾回收

这让我联想到哈希表扩容机制中的平方探测法,中途可能会有空位,但是因为你是按平方来探测的,所以很可能错过,实际上就是因为不连续

而线程池是一次性分配,比如说我创建8个线程,那么就占据了连续的8个车位,没有频繁的分配与释放,不会产生碎片

总结一下线程池的优势:

1.高并发场景下的吞吐量高

2.响应速度快

3.稳定:体现在三个方面:

通过统一的管理与调度(互斥锁、条件变量协调...)避免了竞态条件、死锁

面对突发流量,直接创建大量线程的话会突然增加系统压力,容易导致服务崩溃,而线程池可以平稳处理

直接创建线程会有内存碎片积累,导致性能下降。线程池实现了资源的稳定复用,可以长期稳定运行

创建数据库连接的开销主要来自:

1.网络协议层面的成本

建立MySQL连接时,需要经过:

(A)TCP三次握手

客户端: SYN → 服务器 客户端: SYN-ACK ← 服务器 客户端: ACK → 服务器

(B)MySQL认证协议

客户端发送能力标志 -> 服务器响应认证方法 -> 客户端发送用户名/密码 -> 服务器验证并返回结果

在这个过程中可能还有SSL握手过程

(C)数据库初始化

分配会话内存、初始化查询缓存、设置字符集和时区等、加载用户权限信息

总结:创建线程和连接的开销主要来自:

1.系统调用:用户态/内核态切换

2.内存管理:分配和初始化内存空间(在页表中建立映射,设置页权限...)

3.网络协议:握手与认证过程

4.缓存失效:冷启动的缓存加载成本

在学习TCP三次握手之前,先弄清楚几个基本问题:

一、两台没有物理线缆连接的计算机之间是如何进行数据传输的?

第一步:将数据打包(写好信,并用信封包装好)

电脑要传输的任何东西,无论是文字、图片还是视频,最终都会被转换成数字格式(0和1)。在传输前,这些数据会被切割成一个个大小合适的数据包。这就像你写了一封长信,但邮局规定每封信不能超重,所以你不得不把信分成几封来寄

每个数据包就像一封信,它包含两部分:

1.有效载荷:就是你真正想发送的数据内容(比如“你好”这两个字)

2.信封:包裹在数据外面的信息,上面写明了寄件人地址(源IP地址)、收件人地址(目标IP地址) 以及其他必要的邮寄信息

这个“信封”的格式,就是我们常说的网络协议,最核心的就是 IP协议

IP地址:就像现实世界的“门牌号”,比如 192.168.1.1。互联网上的每一台设备都有一个IP地址,用来标识自己在哪里。数据包就是靠这个地址在网络上被路由和传递的

第二步:找到路径(路由器充当邮局角色)

路由器就像一个智能邮局分拣中心。它的工作就是查看数据包“信封”上的目标IP地址,然后决定接下来该往哪个方向发送,才能让它离目的地更近一步

电脑并不需要一根长长的网线直接连到服务器。它只需要连接到本地网络(比如Wi-Fi路由器)。你的路由器再连接到你的网络服务提供商的路由器(比如电信、联通),ISP的路由器又连接到互联网上其他更大的路由器……如此层层连接,形成了一张覆盖全球的网

当你的数据包发出后,它先到达路由器。路由器根据目标地址,把它发给下一个更近的“分拣中心”(上游路由器)。下一个路由器再做同样的判断,继续转发。这个过程一直重复,直到数据包到达目标服务器所在的网络,最后由服务器的本地路由器交给服务器本身

第三步:可靠的传输(TCP)

只用IP协议发送数据包,你无法知道对方收没收到,信的顺序对不对(可能后发的信先到了)。我们需要一个更可靠的机制来保证传输,这就是 TCP协议的工作

TCP建立在IP之上,它负责管理这些数据包的传输,确保:

不丢失:如果数据包丢了,它会重新发送

按顺序:保证数据包按照发送的顺序被组装

不错误:校验数据在传输过程中没有损坏

所谓的三次握手,正是TCP协议在开始正式传输数据前,为了建立这样一个可靠的连接而进行的“打招呼”过程

更精确地说,TCP是面向连接的、可靠的、基于字节流的传输层通信协议

面向连接,指的是一定是“一对一”才能连接

另外,在上上个项目(Go语言分布式聊天系统)中给我的一个警示是:用户消息通过TCP协议传输时,接收方是不知道消息的边界的,所以在发送真正的消息之前,需要告诉接收方发送消息的长度

二、这个世界上每一台计算机都有自己的IP地址吗?为什么好像很多都是以192.168开头的?

IP地址分为两大类:公网IP地址、私网IP地址

公网IP地址就像家庭住址,它在整个互联网上是唯一的。当你想接收一封从国外寄来的信(数据包)时,你必须有一个全球邮局系统都承认的、唯一的公网地址。公网IP由特定的机构统一分配和管理

私网IP地址就像每个房间的编号,比如201房、202房。这些地址只在你的内部网络(比如你家或公司)里有效。你可以随意给房间编号,但你不能直接用201房这个地址从国外收快递,因为全世界有无数个201房

如果不使用私网IP,那公网IP地址(IPv4版本)会不够用

IPv4地址是类似 192.168.1.1 这样的格式,它理论上只能提供约42.9亿个地址。随着设备数量的爆炸式增长,这个数量远远不够。私网IP就是为了解决这个问题而诞生的

解决方案:网络地址转换(NAT)技术

比喻:

你的路由器就是公司的前台总机

路由器的公网IP就是公司的总机号码(比如 123.123.123.123

你家里设备的私网IP(如 192.168.1.101)就是员工的分机号

通信过程:

1.当你电脑 (192.168.1.101) 想访问谷歌时,数据包先发到路由器(前台)

2.路由器(前台)收到后,会把发件地址从“192.168.1.101”改成了自己的公网地址“123.123.123.123”,并在一个本子上记下“192.168.1.1018080 端口正在访问谷歌”。这个过程叫做 “源地址转换”

3.数据包带着路由器公网IP作为寄件地址,发往谷歌

4.谷歌回复的数据包会寄到“123.123.123.123”(公司前台)

5.路由器(前台)收到后,查看本子,发现这是回复给 192.168.1.101 的数据,于是再把收件地址改回 192.168.1.101,转交给你的电脑

通过这种方式,家里的所有设备在外面看来,都只是一个公网IP地址在活动。这极大地节约了公网IP资源

在同一个路由器下,每一台计算机都有自己的IP(私网IP),因为这个IP是路由器给它分配的,这个过程是由一个叫做 DHCP的协议自动完成的。当你的手机或电脑连接到Wi-Fi时,它会自动向路由器“喊”一句:“我是新来的,谁能给我个地址?” 路由器上的DHCP服务就会回应:“给你这个IP地址 192.168.1.105。” 这样就实现了动态分配

❓追问:那路由器的IP是不是只是它的上游路由器给它分配的?

一个路由器通常至少有两个接口:

WAN 口:连接上层网络,比如你家的光猫/路由器

LAN 口:连接内部设备,比如你的电脑和手机

对于LAN口:它在你的家庭内部网络中,有一个私网IP(比如 192.168.1.1)。这个地址在你家内部是老大,但对于外界来说没有意义

对于WAN口:它从你的运营商那里获取到一个IP地址

也就是说,设备IP是路由器分配给它的,路由器WAN口IP是运营商分配给它的,运营商的IP是区域互联网注册机构分配给它的,区域互联网注册机构的IP是国际组织分配给它的。只有国际组织分配的IP才是绝对IP. 这就好像老板手下有5个经理,每个经理手下有5个员工,也许员工A和员工B的编号都是001,但是它们的经理是不同的,而对于每个经理来说,自己的员工的编号都是唯一的

正面回答这个问题:路由器的WAN口IP是上游路由器IP分配的

那么LAN口IP呢?

你可以自己分配。路由器在出厂时就被烧录了一个默认的LAN口IP,比如192.168.1.1192.168.0.1

当你第一次设置路由器时,可以修改它的LAN口IP

也就是说,运营商只保证路由器的WAN口IP在其网络下唯一,至于LAN口IP,你拥有极高的自主权。毕竟上文也说了,LAN口IP对它下层的设备来说是老大,对外界没有意义

为什么好像很多都是以192.168开头的?

国际组织特意划出了几段IP地址范围,规定它们只能用作私网IP,不能在公网上路由。这些地址段是:

10.0.0.0 - 10.255.255.255 (一个非常大的A类私网段)

172.16.0.0 - 172.31.255.255 (连续的B类私网段)

192.168.0.0 - 192.168.255.255 (连续的C类私网段)

192.168.x.x 是其中最常用的一段,被绝大多数家用路由器设置为默认的局域网网段

所以,很多设备是 192.168 开头,是因为:

1.它们都处于某个内部局域网中(家、公司、咖啡馆)

2.它们通过一个具有公网IP的路由器(利用NAT技术)共享上网

3.192.168.x.x 是家用路由器最流行的默认设置

总结:

不是每台电脑都有唯一的公网IP

大多数设备使用的是私网IP(如 192.168.x.x),它们只在内部网络有效

通过 NAT技术,一个路由器可以带领整个家庭或公司的所有设备,共享一个公网IP访问互联网

什么是TCP三次握手?

比喻理解:打电话

A:喂,能听到吗?(第一次握手:A要确认B是否在线以及能否听到自己的声音)

B:可以听到,你听得到吗?(第二次握手:B确认了自己能听到A的声音,要确认A能否听到自己的声音)

A:可以听到,开始说吧!(第三次握手:A确认了自己能听到B的声音,要让B确认自己能听到B的声音)

TCP握手同理:

客户端:你好,能建立连接吗?(SYN)

服务器:收到,我准备好了,你呢?(SYN-ACK)

客户端:我也准备好了,开始传数据吧!(ACK)

这些SYN、ACK是什么?为了搞懂这个,我们先学习一下TCP头的格式

TCP头格式包含:源端口号(16位)、目标端口号(16位)、序列号(32位)、确认应答号(32位)、首部长度(4位)、保留(6位)、控制位(6位)、窗口大小(16位)、校验和(16位)、紧急指针(16位)、选项(长度可变)、数据

详细说说6个控制位:

1. URG - 紧急指针有效

当这个标志位被设置为1时,它告诉接收方这个数据包里有紧急数据,需要优先处理。它需要和头部的紧急指针字段配合使用

例如,在远程命令行操作中,用户突然按下 Ctrl+C 来中断一个正在运行的命令,这个中断信号就可以被标记为紧急数据,希望接收方能立即处理,而不是在接收缓冲区里排队

2. ACK - 确认号有效

当ACK=1时,表示头部的确认应答号字段(ack)是有效的。TCP规定除了最初建立连接时的SYN包之外,该位必须设置为1

3. PSH - 推送功能

用于催促接收方的应用程序立刻从TCP接收缓冲区中读取数据。通常情况下,为了提高效率,TCP会等接收缓冲区攒到一定量的数据后再提交给应用程序。但当发送方设置PSH=1时,就相当于告诉接收方,这边的数据已经是一个完整的消息了(比如一个HTTP请求),需要立刻交给上层应用程序处理。这样接收方的TCP栈在收到PSH=1的包后,会立即将数据交付给应用程序,而不是等待缓冲区填满

4. RST - 连接重置

当RST=1时,表示要求立即重置连接

例如:

拒绝连接:当服务器收到一个连接请求(SYN包),但目标端口没有进程在监听时,会回复一个RST包

异常终止:当一方发现通信出现不可恢复的错误,或者想立即断开连接时(而不是通过正常的四次挥手),可以发送RST包。这相当于直接拔掉电话线,而不是说再见

处理半开连接:一方已经崩溃重启,另一方还认为连接存在。当重启方收到来自另一方的数据包时,由于它已经不记得这个连接了,会回复一个RST包来告知对方

5. SYN - 同步序列号

用于建立连接

简单理解:SYN=1 是发起和同意握手的信号

6. FIN - 结束连接

用于正常关闭连接

在四次挥手中,当一方数据发送完毕,想要关闭连接时,会发送一个FIN=1的报文段

第一次挥手 (A -> B):FIN=1, ACK=1。意思是:“我这边没有数据要发送了,我想关闭连接。”(但还可以接收数据)

另一方收到后,会先确认这个FIN请求,等自己的数据也发送完毕后,再发送自己的FIN包

了解了控制位后,我们再来分析TCP三次握手:

客户端:你好,能建立连接吗?(SYN=1,ACK=0,seq=x)

服务端:收到,我准备好了,你呢?(SYN=1,ACK=1,seq=y,ack=x+1)

客户端:我也准备好了,开始传数据吧!(SYN=1,ACK=1,seq=x+1,ack=y+1)

seq是序列号,表示本次发送数据的起始编号

ack是确认号(上文提及过),是期望对方下一次发送数据的起始编号,它的潜台词是:你发给我的,编号在ack-1及其之前的数据我都收到了,我接下来希望你从第ack号开始发

第一次握手:Client -> Server,包含:

SYN=1(我要建立连接)

ACK=0(因为这是第一个包,所以没有什么需要确认的)

seq=x(我打算从我这边的第x号话筒说我的第一句话(实际上就是第一次握手的信息),这个x是操作系统内核随机生成的一个值,称为ISN)

此时,Client进入SYN-SENT状态

第二次握手:Server->Client

Server收到后,明白了两件事:

1.Client的初始序列号是x

2.Client想要和我建立连接

Server回复一个包,包含:

SYN=1(我同意建立连接)

ACK=1(我这个包里的ack字段是有效的)

seq=y(那我就从我这边的第y号话筒说我的第一句话(实际上就是第二次握手的信息),这个y是Server操作系统内核随机生成的一个值)

ack=x+1(我已经准备好听你从你的第x+1号话筒说话了)(因为第一次握手时Client已经占用了第x号话筒)

此时Server进入SYN-RECEIVED状态

第三次握手:Client -> Server

Client收到Server的回复后,也明白了两件事:

1.Server的初始序列号为y

2.Server已经准备好接收我从x+1号话筒传输的数据

此时Client认为连接已建立,进入ESTABLISHED状态

Client回复一个包,包含:

SYN=0(连接已经建立,不需要发送申请或告知同意)

ACK=1(我这个包的ack字段是有效的)

seq=x+1(我准备从第x+1号话筒说话了,这正好符合Server的预期)

ack=y+1(我也准备好听你从你的第y+1号话筒讲话了)

Server接收到此消息后,认为连接已建立,进入ESTABLISHED状态

❓追问:ack=x+1为什么是表示所有序列号小于x+1的字节都收到了?不是仅仅确定了Client消息的起始编码为x吗?x之前的信息Server也不知道啊,因为那不是Client的os内部分配的序号吗?

答:一个TCP连接有自己的宇宙,这个宇宙是第一次握手之后才诞生的。在这个宇宙里,序列号只对本次连接有效。对于这次连接而言,在序列号x之前,什么都不存在。就好比我们认为在宇宙大爆炸之前不存在世界一样,事实上宇宙大爆炸之前,这个世界究竟是什么样的,没有人能说清楚

所以,ack=x+1的真正含义是:

1.确认创世事件:所有发生在x+1之前的事件我都确认已知晓

2.表达未来期望:既然事件x已经发生,那么我们这个宇宙的下一个事件编号就应该从x+1开始

也就是说,这个“已知晓”是相对于这个TCP连接来说的,而不是相对于Client的操作系统来说的

操作系统的角色是多元宇宙的管理者。当它收到一个数据包,知道了源IP、源端口、目标IP、目标端口,它就能唯一确定这是属于哪个宇宙的数据

一个更技术的视角:传输控制块(TCB)

(上文的线程控制块也是TCB,一个是Transmission,一个是Thread)

在操作系统的网络栈中,每一个TCP连接都有一个对应的数据结构,通常称为 TCB

这个TCB里就存储了这个连接独有的本地IP/端口、远端IP/端口、本地当前序列号、远端下一个期望序列号、发送窗口、接收窗口等等状态信息。

TCB就是那个“独立宇宙”的物理载体

再回到微观视角:之所以这些设备能够以某种特定的方式进行交互,本质上是它们的操作系统在指挥硬件执行指令的结果。操作系统本身就是一个庞大、复杂、拥有最高权限的常驻内存程序