sdg-notes
__ __ __ __ ____ ___
/\ \ /\ \ /\ \ __ /\ \ /\ _`\ /\_ \
\ `\`\\/'/__ __ ___\ \ \___ /\_\\ \/ ____ \ \ \L\ \//\ \ ___ __
`\ `\ /'/\ \/\ \ /'___\ \ _ `\/\ \\/ /',__\ \ \ _ <'\ \ \ / __`\ /'_ `\
`\ \ \\ \ \_\ \/\ \__/\ \ \ \ \ \ \ /\__, `\ \ \ \L\ \\_\ \_/\ \L\ \/\ \L\ \
\ \_\\/`____ \ \____\\ \_\ \_\ \_\ \/\____/ \ \____//\____\ \____/\ \____ \
\/_/ `/___/> \/____/ \/_/\/_/\/_/ \/___/ \/___/ \/____/\/___/ \/___L\ \
/\___/ /\____/
\/__/ \_/__/ '`
epll/wait: reactor模式:不停轮询,发现有事做,就做! asio: proactor模式,先注册好事件,如果事情发生了,通过回调函数处理。
互斥
所有线程共享一个共同的的时间(不必是同一个公共时钟)。一个线程是一个状态机,其状态的转换称为事件。
两个线程在“start of danger zone”那一行读了value域的值,随后又都在“end of danger zone”那一行修改了value域的值。这就造成了data race. 我们将这两行代码放入临界区内:某个时刻仅能被一个线程执行的代码段。
如果一个线程满足下列条件,则称它是良构的:
- 一个临界区只和唯一的mutex对象相关联,
- 线程准备进入临界区时申请占有mutex,
- 线程离开临界区时申请释放mutex。
自旋锁与争用
任何互斥协议都会产生这样的问题:如果不能获得锁,应该怎么做?对此有两种选择。一种方案是让其继续进行尝试,这种锁称为自旋锁,对锁的反复测试过程称为旋转或忙等待。在希望锁延迟较短的情形下,选择旋转的方式比较合乎情理。另一种方案就是挂起自己,请求操作系统调度器在处理器上调度另外一个线程,这种方式称为阻塞。由于从一个线程切换到另一个线程的代价比较大,所以只有在允许锁延迟较长的情形下,阻塞才有意义。许多操作系统将这两种策略综合起来使用,先旋转一个小的时间段然后再阻塞。旋转和阻塞都是重要的技术。
争用指多个线程试图同时获得一个锁;高争用则意味着存在大量正在争用的线程;低争用的意思与高争用相反。
小米面经之二
- 自我介绍,实习工作内容
- 局部静态对象和全局静态对象有什么区别
- 进程间通信的方式
- 有哪些同步原语
- 堆内存和栈内存的区别
int a, *b;
有什么区别?(考察指针默认不开辟内存)
函数模板
6.2.4 重载与特化 为编译到函数模板的调用,编译器必须在非模板重载、模板重载和模板重载的特化间决定。
template< class T > void f(T); // #1 :模板重载
template< class T > void f(T*); // #2 :模板重载
void f(double); // #3 :非模板重载
template<> void f(int); // #4 : #1 的特化
f('a'); // 调用 #1
f(new int(1)); // 调用 #2
f(1.0); // 调用 #3
f(1); // 调用 #4
注意只有非模板和初等模板重载参与重载决议。特化不是重载,且不受考虑。只有在重载决议选择最佳匹配初等函数模板后,才检验其特化以查看何为最佳匹配。
template< class T > void f(T); // #1 :所有类型的重载
template<> void f(int*); // #2 :为指向 int 的指针特化 #1
template< class T > void f(T*); // #3 :所有指针类型的重载
f(new int(1)); // 调用 #3 ,即使通过 #1 的特化会是完美匹配
即重载的优先级要高于特化。 关于模板函数重载的更多内容,参考function_template。
memcpy 和 memmove 的区别
memcpy不能应对内存重叠,memmove可以。详见man pages.
编写多线程需要注意的点
- 在脑中先大致想好每个线程的工作是什么,什么时候开始,什么时候结束。
- 捋清楚了之后再开始动手写。
调用t.join()
的作用类似于,如果线程结束,主线程执行到join就可以立即返回,如果线程为结束,主线程执行到join会阻塞,直到线程结束。然后主线程继续执行。
main thread1
+ +
| |
| |
| |
| |
thread1.join()+------+
|
|
|
v
如果某线程申请占有互斥量时,该互斥量被其他线程占有,则会引起该线程阻塞。观察者模式在计算机系统中使用甚广。
线程安全性两个主要保证:
- 原子操作
- CAS操作
使用std::conditional_variable
注意事项
- 调用wait的线程必须占有mutex,否则undefined
- 所有并发线程(如果使用同一个条件变量交互)必须使用同一个mutex,否则undefined
使用std::thread
注意事项
- thread对象构造完成即开始执行
- 使用detach之后,程序失去该线程的控制权,线程结束之后资源全部释放
- 使用detach之后,主线程结束时,所有资源都被释放,即便该线程还未停止
- 线程之间没有父/子关系。如果线程A创建线程B,然后线程A终止,则线程B将继续执行。但如果主线程终止,则整个进程终止,自然进程下的所有线程都终止,资源释放
- Any thread can potentially access any object in the program (objects with automatic and thread-local storage duration may still be accessed by another thread through a pointer or by reference).
使用std::atomic
注意事项
- atomic is neither copyable nor movable
- mutex is neither copyable nor movable
加一次锁耗时大概25ns,使用lock-free的话能够提高到十几纳秒,事实上提升不大。加锁并没有想象中那么耗时,提高效率的关键是减少锁的碰撞。即一个线程占有锁的时候,其他线程不会去申请锁,因为在锁被占用的情况下去申请锁比较耗时,会先去loop一段时间,拿不到锁才会进入内核陷入睡眠等待锁,这样的耗时是比较浪费的。所以关键要减少锁的碰撞。
有原子的函数吗,就是要么执行成功,要么失败? 不存在,一个函数内部多少指令,在多线程的情况下,很难保证可以全部的顺序的原子的执行完成。
From Shuo’s blog
依据《Java 并发编程实践》/《Java Concurrency in Practice》一书,一个线程安全的 class 应当满足三个条件:
- 从多个线程访问时,其表现出正确的行为
- 无论操作系统如何调度这些线程,无论这些线程的执行顺序如何交织
- 调用端代码无需额外的同步或其他协调动作
对象构造要做到线程安全,惟一的要求是在构造期间不要泄露 this 指针,即
- 不要在构造函数中注册任何回调
- 也不要在构造函数中把 this 传给跨线程的对象
- 即便在构造函数的最后一行也不行
作为 class 数据成员的 Mutex 只能用于同步本 class 的其他数据成员的读和写,它不能保护安全地析构。因为成员 mutex 的生命期最多与对象一样长,而析构动作可说是发生在对象身故之后(或者身亡之时)。另外,对于基类对象,那么调用到基类析构函数的时候,派生类对象的那部分已经析构了,那么基类对象拥有的 mutex 不能保护整个析构过程。
不要在ctor里调用虚函数
总结来说:基类部分在派生类部分之前被构造,当基类构造函数执行时派生类中的数据成员还没被初始化。如果基类构造函数中的虚函数调用被解析成调用派生类的虚函数,而派生类的虚函数中又访问到未初始化的派生类数据,将导致程序出现一些未定义行为和bug。
ctor应该设计的尽量简单,确保对象可以被正确构造。在ctor中调用本类的非静态成员都是不安全的,因为他们还没被构造,而有些成员是依赖对象的,而此时对象还没有被成功构造。
ctor不能是虚函数
-
从存储空间角度:虚函数对应一个vtable(虚函数表),这大家都知道,可是这个vtable其实是存储在对象的内存空间的。问题出来了,如果构造函数是虚的,就需要通过 vtable来调用,可是对象还没有实例化,也就是内存空间还没有,无法找到vtable,所以构造函数不能是虚函数。
-
从使用角度:虚函数主要用于在信息不全的情况下,能使重载的函数得到对应的调用。构造函数本身就是要初始化实例,那使用虚函数也没有实际意义呀。所以构造函数没有必要是虚函数。
虚函数的作用在于通过父类的指针或者引用来调用它的时候能够变成调用子类的那个成员函数。而构造函数是在创建对象时自动调用的,不可能通过父类的指针或者引用去调用,因此也就规定构造函数不能是虚函数。
- 构造函数不需要是虚函数,也不允许是虚函数,因为创建一个对象时我们总是要明确指定对象的类型,尽管我们可能通过实验室的基类的指针或引用去访问它。但析构却不一定,我们往往通过基类的指针来销毁对象。这时候如果析构函数不是虚函数,就不能正确识别对象类型从而不能正确调用析构函数。
④从实现上看,vbtl在构造函数调用后才建立,因而构造函数不可能成为虚函数
从实际含义上看,在调用构造函数时还不能确定对象的真实类型(因为子类会调父类的构造函数);而且构造函数的作用是提供初始化,在对象生命期只执行一次,不是对象的动态行为,也没有太大的必要成为虚函数
⑤当一个构造函数被调用时,它做的首要的事情之一是初始化它的V P T R。因此,它只能知道它是“当前”类的,而完全忽视这个对象后面是否还有继承者。当编译器为这个构造函数产生代码时,它是为这个类的构造函数产生代码- -既不是为基类,也不是为它的派生类(因为类不知道谁继承它)。 ———————————————— 版权声明:本文为CSDN博主「cainiao000001」的原创文章,遵循CC 4.0 by-sa版权协议,转载请附上原文出处链接及本声明。 原文链接:https://blog.csdn.net/cainiao000001/article/details/81603782
虚函数的工作原理
https://zhuanlan.zhihu.com/p/60543586
C++ 规定了虚函数的行为,但将实现方法留给了编译器的作者。不需要知道实现方法也可以很好的使用虚函数,但了解虚函数的工作原理有助于更好地理解概念。
通常,编译器处理虚函数的方法是:给每个对象添加一个隐藏成员。隐藏成员中保存了一个指向函数地址数组的指针。
这种数组称为虚函数表(Virtual Function Table, vtbl)。
虚函数表是一个数组,数组的元素是指针,指针指的是虚函数的地址。
具有虚函数的类的实例,都会在头部存一个指向虚函数表的指针。
常见类型所占空间大小
单位:bytes
- (unsigned) int: 4
- (unsigned) short: 2
- (unsigned) long: 8
- float: 4
- double: 8
- long double: 16
- (unsigned) char: 1
- bool: 1
指针占几个字节 指针即为地址,指针几个字节跟语言无关,而是跟系统的寻址能力有关,譬如以前是16为地址,指针即为2个字节,现在一般是32位系统,所以是4个字节,以后64位,则就为8个字节。
析构函数的调用
The destructor is called whenever an object’s lifetime ends, which includes
- program termination, for objects with static storage duration
- thread exit, for objects with thread-local storage duration
- end of scope, for objects with automatic storage duration and for temporaries whose life was extended by binding to reference
- delete-expressin, for objects with dynamic storage duration
- end of the full expression, for nameless temporaries
- stack unwinding (栈回溯), for objects with automatic storage duration when an exception escapes their block, uncaught.
c.f. https://en.cppreference.com/w/cpp/language/destructor
几个常用的宏
__func__
: name of an function, exists in C99/C++11 (__FUNCTION__
is non standard)__LINE__
: line number of the code__FILE__
: filename of the file__DATE__
and__TIME__
: as you wish
Joseph Ring
牛客网上类似的题: https://www.nowcoder.com/questionTerminal/f78a359491e64a50bce2d89cff857eb6
描述:人们站在一个等待被处决的圈子里。 计数从圆圈中的指定点开始,并沿指定方向围绕圆圈进行。 在跳过指定数量的人之后,执行下一个人。 对剩下的人重复该过程,从下一个人开始,朝同一方向跳过相同数量的人,直到只剩下一个人,并被释放。
问题即,给定人数、起点、方向和要跳过的数字,选择初始圆圈中的位置以避免被处决。
解法:维基百科上也有,GeeksforGeeks还有视频教程。 https://en.wikipedia.org/wiki/Josephus\_problem https://www.geeksforgeeks.org/josephus-problem-set-1-a-on-solution/
常见的有两种解法:
- 单链表模拟
- 数学递推⇒递归求解
显然,假设n个人编号:0,1,2,3,…,n-1. 从0号开始报数(报数从0开始),报到m-1的将被处决,然后从下一个人开始报数。直到剩下最后一个人,赦免之。
第一趟:报到m的自然是编号为(m-1)%n. 接着从 m%n 开始报数,接下来又会是谁被处决呢?
等等,先来看看问题是什么,我希望知道幸免者的编号。在n个人,报m个数的设定下,我希望知道幸免者编号,假设这个编号就是f(n,m).
在第一趟之后,报数从编号k=m%n开始,但是此时只有n-1个人,我还是想知道幸存者的编号。如果此时将编号重新映射一下,比如:
k -> 0
k+1 -> 1
...
k-2 -> n-2
那么问题就变成了n-1个人,从0开始报数,报到m-1被处决,完完全全成了一个拥有同样结构的问题,但是规模更小了。显然,这个问题的解是 f(n-1, m). 但是呢,我们得到的编号却不是原来的编号了,得把编号还原回去。这很简单,假设得到的编号是x,那么映射回原编号x’
x' = (x+k)%n
于是,如果我们能够知道 f(n-1, m), 那么 f(n, m) = (f(n-1,m) + m)%n. 这就得到了递推公式。接着看一下边界条件,当n = 1时, f(1, m) = 0; 结束。
C++内存布局
结构体
C++规范在“结构”上使用了和C相同的,简单的内存布局原则:成员变量按其被声明的顺序排列,按具体实现所规定的对齐原则在内存地址上对齐。
struct S {
char a; // memory location #1
int b : 5; // memory location #2
int c : 11, // memory location #2 (continued)
: 0,
d : 8; // memory location #3
struct {
int ee : 8; // memory location #4
} e;
} obj; // The object 'obj' consists of 4 separate memory locations
- 类的静态成员不占用类的空间,静态成员在程序数据段中。
UDP介绍
摘自《计算机网络 第四版》
UDP面向无连接,它传输的数据段(segment)是由8字节的头和净荷域构成的。头包含源端口和目标端口,各占16位,共4字节。两个端口分别被用来标识源机器和目标机器内部的端点。当一个UDP分组到来的时候,它的净荷部分被递交给与目标端口相关联的那个进程。这种关联关系是在调用了bind原语或者其他某一种类似的做法之后建立起来的。实际上,采用UDP而不是原始的IP,其最主要的价值是增加了源端口和目标端口。如果没有端口域,则传输层将不知道该如何处理分组;而有了端口之后,它就可以正确地提交数据段了。
当目标端必须将一个应答送回给源端地时候,源端口是必须地。发送应答的进程只要将进来的数据段中的source port域复制道输出的数据段中的destination port域,就可以指定在发送方机器上由哪个进程来接受应答。
另外值得提出来的可能是UDP没有做到一些事情。UDP并不考虑流控制、错误控制,在收到一个坏的数据段之后它也不重传。所有这些工作都留给用户进程。UDP所作的事情就是提供一个接口,并且在接口中增加复用(demultiplexing)的特性。他利用端口的概念将数据段解复用到多个进程中。这就是它所做的全部工作。
UDP尤其适用域C-S架构下,客户端给服务器发送一个短的请求,并且期望一个短的应答回来,如果请求或者应答丢失,只需要超时重传。
UDP的一个应用时DNS(Domain Name System),简单来说,如果一个程序需要根据某一个主机名(比如www.cs.berkeley.edu)来查找它的IP地址,那么,它可以给DNS服务器发送一个包含该主机名的UDP分组。服务器用一个包含该主机IP地址的UDP分组作为应答。实现不需要建立连接,事后也不需要释放连接。在网络上只要两条消息就够了。DNS在进行区域传输(主从dns server之间的数据同步)的时候使用TCP,普通的查询使用UDP。因为普通查询数据量小,比较适合用udp这种速度更快。
RPC
从某种意义上讲,向一台远程主机发送一个消息并获得一个应答,就如同在编程语言中执行一个函数调用一样。在这两种情况下,你都要提供一个或多个参数,然后获得一个结果。这种现象导致人们试图将网络上的请求-应答交互过程,做成像过程调用那样可以进行类型匹配和转换。这样的结构是的网络应用更加易于编程,而且人们对这种处理方式也更加熟悉。例如,假设有一个名为getIPaddress(hostname)
的过程,它的工作方式为:向DNS服务器发送一个UDP分组,然后等待应答,如果在规定的时间内没有接收到应答的话,则超时并重试。通过这种方式,网络的所有细节对于程序员而言全部隐藏。
这个领域中的关键工作由Birrel和Nelson(1984)完成。简单来说:允许本地的程序调用远程主机上的过程。当机器A上的进程调用机器B上的一个过程的时候,机器A上的调用进程被挂起,而机器B上被调用的过程则开始执行。参数信息从调用方传输到被调用方,而过程的执行结果则从反方向传递回来。对于程序员而言,所有的消息传递都是不可见的。这项技术称为RPC(Remote Procedure Call),目前已成为许多网络应用的基础。按照传统,调用过程称为客户,被调用过程称为服务器。
当然,RPC不一定非得使用UDP分组,但是,RPC和UDP是一对很好的搭档,而且,UDP常常被用于RPC。然而,当参数或者结果值可能超过最大的UDP分组的时候,或者当所请求的操作并不幂等(即不能安全地重复多次执行,比如计数器递增地操作)的时候,可能有必要建立一个TCP连接,然后利用该连接来发送请求,而不是使用UDP来完成远程调用。
RTC
UDP的还被广泛应用在实时多媒体:Internet广播电台、Internet电话、音乐点播、视频会议、视频点播等。人们发现每一种应用都在重复设计几乎相同的实时传输协议,逐渐地人们意识到,为多媒体应用制定一个通用的协议是一个很好的想法,因此就诞生了RTP(Real-time Transport Protocol)。
TCP介绍
UDP是一个简单的协议,它有一些非常合适的用途。但是对于大多数Internet应用来说,他们更需要可靠的,按序递交的特性。所以还需要另一个协议,这就是TCP,目前它是Internet上承担任务最为繁重的一个协议。
TCP是专门为了在不可靠的互联网上提供一个可靠的端到端字节流而设计的。每台支持TCP的机器都有一个TCP传输实体,它或者是一个库过程,或者是一个用户进程,或者是内核的一部分。在所有这些情形下,它管理TCP流,以及与IP层之间的接口。TCP传输实体接受本地进程和用户数据流,并且将他们分割成不超过64KB(在实践中,考虑到每个帧中都希望有IP和TCP头,所以通常不超过1460数据字节)的分片,然后以单独的IP数据报的形式发送每一个分片。当包含TCP数据的数据报到达一台机器的时候,他们被递交给TCP传输实体,然后TCP传输实体再重构出原始的字节流。
IP层并不保证数据报一定被正确的递交到目标端,所以TCP需要判断超时的情况,并且需要根据需要重传数据报。即使被正确递交的数据报,也可能存在错序的问题,这也是TCP的责任,他必须把接收到的数据报按照正确的顺序重新装配成用户消息。
TCP服务模型
要想获得TCP服务,发送方和接收方必须创建一种被称为套接字的端点。每个套接字有一个套接字号(地址),它是由主机的IP地址以及本地主机局部的一个16为数值组成的,此16为数值被称为端口(port)。端口是一个TSAP的TCP名字。为了获得TCP服务,首先必须要显示的再发送机器的套接字和接受机器的套接字之间建立一个连接。
一个套接字有可能同时被用于多个连接。换句话说,两个或者多个连接可能终止与同一个套接字。每个连接可以用两端的套接字标识符来标识,即(socket1, socket2)。TCP不适用虚电路号或者其他的标识符。
1024以下的端口号被称为知名端口(well-known port),其实就是系统保留端口,有很多约定的服务和特定的端口号对应,如ssh默认端口号是22.
所有的TCP连接都是全双工的,并且是点到点的。所谓全双工,意味着同时可在两个方向上传输数据;二点到点则意味着每个连接恰好有两个端点。TCP并不支持多播或者广播传输模式。
一个TCP连接就是一个字节流,而不是消息流。端到端之间并不比保留消息的边界。例如,如果发送进程将4个512字节的数据块写到一个TCP流中,那么在接收进程中,这些数据有可能按4个512字节快的方式被递交,也可能是2个1024字节的数据块,或是一个2048字节的数据块,或者其他方式。接收方无法获知这些数据被写入字节流时候的单元大小。
正如Unix中文件一样,读文件的程序无法判断该文件是怎么写成的,是一次性还是分块写入,然而,程序也无意于去弄清这个事情。一个TCP软件不理解TCP字节流的含义,也无意于弄清其含义,一个字节就是一个字节而已。
当一个应用将数据传递给TCP的时候,TCP可能立即将数据发送出去,也可能将它缓冲起来(为了收集更多的数据从而一次发送出去),这完全由TCP软件自己来决定。然而,有时候应用程序确实希望自己的数据立即被发送出去,例如,假设一个用户已经登陆到一台远程服务器上,用户每输入一行命令就会敲入回车键,这时候该命令行应该被立即发送到远程主机,而不应该缓冲起来等待下一行命令。为了强迫将数据发送出去,应用程序可以使用PUSH标志,它相当于告诉TCP不要延迟传输过程。
有关TCP服务的最后一个值得在这里提出来的特性是紧急数据(urgent data)。当一个交互用户通过敲入DEL或者CTRL-C来打断一个已经开始运行的远程计算过程的时候,发送方应用把一些控制信息放在数据流中,然后将它联通URGETN标志一起交给TCP。这一事件将使得TCP停止继续积累数据,而是将该连接上已有的所有数据立即传输出去。当目标端接收到紧急数据的时候,接收方应用被中断(比如,按Unix的术语来说得到了一个信号),所以它停止当前正在做的工作,并且读入数据流以找到紧急数据。紧急数据的尾部应该被标记出来,所以,如何发现紧急数据要取决于具体的应用程序。这种方案基本上只是提供了一种原始的信号机制,其余的工作全部留给应用程序自己来处理。
TCP协议
TCP的一个关键特征,也是主导了整个协议设计的特征是,TCP连接上的每个字节都有它独有的32位序列号。发送端和接收端的TCP实体以数据段的形式交换数据。TCP数据段(TCP segment)是由一个固定的20字节的头(加上可选的部分)以及随后的0个或者多个数据字节构成的。TCP软件决定数据段的大小,它可以将多次写操作的数据累积起来放到一个数据段中,也可以将一次写操作的数据分割到多个数据段中。有两个因素限制了段的长度:第一,每个数据段,包括TCP头在内,必须适合IP的65515字节净荷大小;其次,每个网络都有一个最大传输单元(Maximum Transfer Unit)MTU,每个数据段必须适合于MTU。在实践中,MTU通常是1500字节(以太网的净荷大小),因此它规定了数据段长度的上界。
TCP连接的建立
TCP使用三步握手法建立连接。为了一个建立一个连接,某一方,比如说服务器,通过执行LISTEN和ACCEPT primitives(既可以指定一个特定的源,也可以不指定)被动地等待一个进来地连接请求。
另一端,比如客户端,执行一个CONNECT primitive,同时指定以下参数:它希望连接地IP地址和端口、它愿意接受地最大TCP分段长度,以及一些可选地用户数据(比如口令)。CONNECT primitive发送一个SYN=1和ACK=0的TCP数据段,然后等待应答。
当这个数据段到达目标端的时候,那里的TCP实体查看一下是否有一个进程已经在Destination port域中指定的端口上执行的LISTEN。如果没有的话,它送回一个设置了RST位的应答,已拒绝客户的连接请求。
如果某个进程正在监听端口,那么,TCP实体将进来的TCP数据段交给该进程。然后该进程可以接受或者拒绝这个连接请求。如果它接受的话,则送回一个确认数据段。在正常情况下发送的TCP数据段顺序如图(a)所示。请注意,SYN数据段只消耗1字节的序列号空间,所以它的确认是非常明确的,毫无二义性。
如果两台主机同时企图在同样的两个套接字之间建立一个连接,则事件序列如上图(b)所示。这些事件的结果是,只有一个连接被建立起来,而不是两个,因为所有的连接都是由它们的端点来标识的。如果第一个请求导致建立了一个由(x,y)标识的连接,而第二个请求也建立了这样一个连接,那么,在TCP实体内部只有一个表项,即(x,y).
TCP连接的释放
虽然TCP连接时全双工的,但是,为了理解TCP连接的释放过程,最好将TCP连接看成一对单工连接。每个单工连接被单独释放,两个单工连接之间相互独立。为了释放一个连接,任何一方都可以发送一个设置了FIN位的TCP数据段,这表示它已经没有数据要发送了。当FIN数据段被确认的时候,这个方向上就停止传送新数据。然而,另一个方向上可能还在继续无限制地传送数据,当两个方向都停止的时候,连接才被释放。通常情况下,为了释放一个连接,需要4个TCP数据段:每个方向一个FIN和一个ACK。然而,第一个ACK和第二个FIN有可能被包含在同一个数据段中,从而将总数降低到3个。
四次挥手。https://blog.csdn.net/qq_33951180/article/details/60767876 https://www.imooc.com/article/17411