2024-12-21 14:21:44
论文(Paper)通常是新技术、算法、编程方法或软件工具的首次公布。通过阅读论文,我们可以了解最新的技术进展,保持自己的技能和知识是最新的。
同时,论文提供了对特定主题深入理解的机会。它们通常包含详细的理论分析和实验结果,这有助于深入理解某个概念或技术。但是,如何高效地阅读论文是一项关键但很少有人谈论的技能。刚开始大部分人自己通过试错来学习,但常常被挫败感所劝退。本文介绍一种阅读论文的方法——三遍阅读法。
关键思想是你应该最多阅读三遍论文,而不是从开头一直读到结尾。每一遍都完成特定的目标,并在前一遍的基础上构建:
快速浏览,获得论文的大致概念。你还可以决定是否需要进行更多的阅读。这一遍一般需要大约 5 到 10 分钟,并包括以下步骤:
在第一遍结束时,你应该能够回答这五个 C(Category、Context、Correctness、Contributions、Clarity):
利用这些信息,你可能会决定不再继续阅读。这可能是因为论文不引起你的兴趣,或者你对该领域不够了解,无法理解论文,或者作者做出了无效的假设。第一遍对于不在你感兴趣的领域内,但将来可能相关的论文来说是足够的。如果读者在五分钟后不能理解论文的亮点,论文很可能永远不会被阅读。
在第二遍中,更仔细地阅读论文,但忽略细节,如证明。在阅读时记下关键点或在边缘做注释会很有帮助。
仔细查看论文中的图表、图表和其他插图。特别注意图形。轴是否正确标记?结果是否显示了误差条,以便结论具有统计意义?这些常见的错误将区分匆忙、粗制滥造的工作和真正优秀的工作。
记得标记相关未读的参考文献以供进一步阅读(这是了解论文背景的好方法)。 第二遍应该需要最多一个小时。在这一遍之后,你应该能够把握论文的内容。你应该能够向别人总结论文的主要论点,并提供支持证据。
这种细节水平适合你感兴趣的论文,但不属于你的研究专业。有时即使你在第二遍结束时也不理解论文。这可能是因为主题对你来说是新的,有不熟悉的术语和缩写词。或者作者使用了一个你不理解的证明或实验技术,以至于论文的大部分内容对你来说是无法理解的。
论文可能写得很糟糕,有未经证实的断言和许多前向引用。或者,可能只是因为现在是深夜,你很累。你现在可以选择:
(a) 把论文放在一边,希望你不需要理解这些材料就能在你的职业生涯中取得成功
(b) 稍后再回到论文,也许在阅读背景材料之后
(c) 坚持下去,进行第三遍阅读。
要完全理解一篇论文。第三遍的关键是尝试虚拟地重新实现论文:也就是说,做出与作者相同的假设,重新创建工作。通过将这个重新创建与实际论文进行比较,你可以很容易地识别出论文的创新之处,以及它的隐藏缺陷和假设。
这一遍需要极大的注意力。你应该识别并挑战每一条陈述中的每一个假设。此外,你应该思考你自己会如何表达一个特定的想法。实际与虚拟的比较可以非常深入地洞察论文中的证明和展示技巧,并且你很可能可以将这些技巧添加到你的工具箱中。在这一遍期间,你还应该记下未来工作的想法。
对于初学者来说,这一遍可能需要大约四到五个小时,对于有经验的读者来说,大约需要一个小时。在这一遍结束时,你应该能够从记忆中重建整篇论文的结构,并且能够识别出它的优点和缺点。特别是,你应该能够指出隐含的假设、遗漏的相关工作的引用,以及实验或分析技术可能存在的问题。
在进行文献综述时,会考验你的论文阅读技能。这将要求你阅读数十篇论文,可能在一个不熟悉的领域。你应该阅读哪些论文?以下是如何使用三遍阅读方法来帮助: 首先,使用学术搜索引擎,如 Google Scholar ,以及一些精心选择的关键词,找到该领域最近的三到五篇论文。
对每篇论文进行一遍阅读,以了解工作,然后阅读它们的相关工作部分。你将找到最近工作的缩略图摘要,也许你很幸运,会找到一个最近的综述论文。如果你能找到这样的综述,你就完成了。
阅读综述,为自己感到幸运。否则,在第二步中,在参考文献中找到共享的引用和重复的作者名字。这些是该领域的关键论文和研究人员。下载关键论文并将它们放在一边。然后,访问关键研究人员的网站,看看他们最近在哪里发表了文章。 这将帮助你确定该领域的顶级会议,因为最好的研究人员通常在顶级会议上发表文章。
第三步是访问这些顶级会议的网站,并浏览他们最近的会议记录。快速浏览通常可以识别出最近的高质量相关工作。这些论文,以及你之前放在一边的论文,构成了你的调查的第一版。对这些论文进行两遍阅读。如果它们都引用了一个你之前没有找到的关键论文,那么获取并阅读它,根据需要进行迭代。
2024-12-21 14:19:56
《UNIX 传奇:历史与回忆》 是 bwk(Brian W. Kernighan)2019 年的新作,回忆了 UNIX 在大半个世纪的风雨历程,是一本引人入胜的书籍。通过对 UNIX 操作系统的历史和发展进行详细的叙述和回顾,让我对这个操作系统有了更深入的了解。读完这本书,我不仅对 UNIX 的技术细节有了更清晰的认识,也对 UNIX 的影响力和价值有了更深刻的体会。
书中首先回顾了 UNIX 的诞生和发展过程,从贝尔实验室的研究项目到成为世界上最重要的操作系统没有之一,UNIX 经历了漫长而曲折的发展历程。作者通过详细的叙述和丰富的历史资料,将 UNIX 的发展与当时的技术环境和社会背景相结合,深入分析了 UNIX 的成功原因和对计算机科学的影响。
在书里,作者还介绍了 UNIX 的设计原则和哲学思想,如小即是美、一切皆文件等,这些原则不仅体现了 UNIX 的简洁和灵活性,也影响了后来的操作系统设计。通过对 UNIX 设计原则的解读,我对 UNIX 的设计理念有了更深入的理解,也对软件设计和开发有了新的思考。
上图(来源)中站着的是 dmr(Dennis MacAlistair Ritchie)、坐着打字的是 Ken(Ken Thompson) 和几台 PDP-11。此外,本书还详细介绍了 UNIX 的核心组件和功能,如文件系统、进程管理、网络通信等等。通过对这些功能的解析,会 UNIX 的内部机制有了更深入的了解,也对操作系统的工作原理有了更全面的认识。同时,书中还介绍了 UNIX 的各种衍生版本和相关技术,如 Linux、BSD(加州大学伯克利分校维护的版本)等,这些衍生版本不仅丰富了 UNIX 的功能和应用领域,也推动了开源软件的发展。
书中除了对 UNIX 技术的介绍,还涉及了 UNIX 社区的发展和文化。UNIX 社区以其开放、自由的精神吸引了众多开发者和用户,形成了独特的文化氛围。通过对 UNIX 社区的描述和分析,我对 UNIX 社区的运作方式和价值观有了更深入的了解。UNIX 社区以其开放的开发模式和共享的文化,促进了知识和经验的交流,推动了技术的不断进步。在 UNIX 社区中,人们通过邮件列表、论坛和会议等形式进行交流和合作,共同解决问题、改进软件,形成了一种合作共赢的氛围。
此外,作者还介绍了 UNIX 在商业领域的应用和发展。UNIX 不仅在学术界和科研领域得到广泛应用,也在商业领域取得了巨大成功。通过对 UNIX 商业化的历史和案例的介绍,对 UNIX 在商业环境中的优势和挑战有了更深入的认识。UNIX 的开放性和灵活性使其成为企业 IT 系统的首选,而 UNIX 商业公司的崛起也推动了 UNIX 的发展和推广。
上图是 1991 年 8 月 林纳斯·托瓦兹 的 Linux宣告(图片来源)。在读《UNIX 传奇:历史与回忆》之后,对 UNIX 的重要性和影响力有了更深刻的认识。UNIX 不仅是一种操作系统,更是一种思想和理念的体现。UNIX 的设计原则和开放的开发模式影响了整个计算机科学领域,推动了软件工程的发展。UNIX 的成功不仅在于其技术实力,更在于其背后的开放和合作精神。
然后,本书还通过对 UNIX 历史的回顾和个人经历的叙述,让我感受到了 UNIX 社区的热情和活力。UNIX 社区的成员们对技术的热爱和追求,以及对自由和开放的坚持,让我深受启发。作为一名从事软件开发的人,我深深地感受到了 UNIX 所传递的价值观和精神,这将对我的工作和职业发展产生积极的影响。
读完《UNIX 传奇:历史与回忆》后,我深受感动和启发。这本书不仅让我了解了 UNIX 的历史和技术,也让我感受到了 UNIX 的精神和价值。UNIX 的开放性、灵活性和合作精神,都是我在工作和生活中需要学习和借鉴的地方。
UNIX 的设计哲学「小即是美」,让我明白了在解决问题时,简洁的解决方案往往是最好的。在软件开发中,我们应该尽量避免复杂性,追求简洁和高效。同时,UNIX 的「一切皆文件」原则,也让我明白了抽象和统一的重要性。通过把所有资源都视为文件,UNIX 简化了操作和管理的复杂性,提高了效率和可用性。
上图是自 1969 年以来 UNIX 和类 UNIX 系统的演变历史(图片来源)。UNIX 开放源代码和开发模式,也让我认识到了开放和共享的价值。在今天的互联网时代,开放和共享是推动技术和知识进步的重要力量。我们应该积极参与开源社区,共享我们的知识和经验,共同推动技术的发展。
此外,UNIX 社区的活力和热情,也让我深受感动。在 UNIX 社区中,人们无私地分享知识,热情地帮助他人,共同解决问题,这种精神是我需要学习和倡导的。
总的来说,《UNIX 传奇:历史与回忆》是一本非常值得一读的书。它不仅让我了解了 UNIX 的历史和技术,也让我感受到了 UNIX 的精神和价值。
这本书对我来说,既是一次知识的旅行,也是一次精神的洗礼。我相信,这本书对任何对计算机科学和软件开发感兴趣的人,都会有所启发和帮助~
2024-12-21 14:10:49
Goroutines 是 Go 语言主要的并发原语。它看起来非常像线程,但是相比于线程它的创建和管理成本很低。Go 在运行时将 goroutine 有效地调度到真实的线程上,以避免浪费资源,因此您可以轻松地创建大量的 goroutine(例如每个请求一个 goroutine),并且您可以编写简单的,命令式的阻塞代码。因此,Go 的网络代码往往比其它语言中的等效代码更直接,更容易理解(这点从下文中的示例代码可以看出)。
对我来说,goroutine 是将 Go 这门语言与其它语言区分开来的一个主要特征。这就是为什么大家更喜欢用 Go 来编写需要并发的代码。在下面讨论更多关于 goroutine 之前,我们先了解一些历史,这样你就能理解为什么你想要它们了。
高性能服务器需要同时处理来自多个客户端的请求。有很多方法可以设计一个服务端架构来处理这个问题。最容易想到的就是让一个主进程在循环中调用 accept,然后调用 fork 来创建一个处理请求的子进程。这篇 Beej’s Guide to Network Programming 指南中提到了这种方式。
在网络编程中,fork 是一个很好的模式,因为你可以专注于网络而不是服务器架构。但是它很难按照这种模式编写出一个高效的服务器,现在应该没有人在实践中使用这种方式了。
fork 同时也存在很多问题,首先第一个是成本: Linux 上的 fork 调用看起来很快,但它会将你所有的内存标记为 copy-on-write。每次写入 copy-on-write 页面都会导致一个小的页面错误,这是一个很难测量的小延迟,进程之间的上下文切换也很昂贵。
另一个问题是规模: 很难在大量子进程中协调共享资源(如 CPU、内存、数据库连接等)的使用。如果流量激增,并且创建了太多进程,那么它们将相互争夺 CPU。但是如果限制创建的进程数量,那么在 CPU 空闲时,大量缓慢的客户端可能会阻塞每个人的正常使用,这时使用超时机制会有所帮助(无论服务器架构如何,超时设置都是很必要的)。
通过使用线程而不是进程,上面这些问题在一定程度上能得到缓解。创建线程比创建进程更“便宜”,因为它共享内存和大多数其它资源。在共享地址空间中,线程之间的通信也相对容易,使用信号量和其它结构来管理共享资源,然而,线程仍然有很大的成本,如果你为每个连接创建一个新线程,你会遇到扩展问题。与进程一样,你此时需要限制正在运行的线程的数量,以避免严重的 CPU 争用,并且需要使慢速请求超时。创建一个新线程仍然需要时间,尽管可以通过使用线程池在请求之间回收线程来缓解这一问题。
无论你是使用进程还是线程,你仍然有一个难以回答的问题: 你应该创建多少个线程?如果您允许无限数量的线程,客户端可能会用完所有的内存和 CPU,而流量会出现小幅激增。如果你限制服务器的最大线程数,那么一堆缓慢的客户端就会阻塞你的服务器。虽然超时是有帮助的,但它仍然很难有效地使用你的硬件资源。
那么既然无法轻易预测出需要多少线程,当如果尝试将请求与线程解耦时会发生什么呢?如果我们只有一个线程专门用于应用程序逻辑(或者可能是一个小的、固定数量的线程),然后在后台使用异步系统调用处理所有的网络流量,会怎么样?这就是一种 事件驱动 的服务端架构。
事件驱动架构模式是围绕 select 系统调用设计的。后来像 poll 这样的机制已经取代了 select,但是 select 是广为人知的,它们在这里都服务于相同的概念和目的。select 接受一个文件描述符列表(通常是套接字),并返回哪些是准备好读写的。如果所有文件描述符都没有准备好,则选择阻塞,直到至少有一个准备好。
1 |
|
为了实现一个事件驱动的服务器,你需要跟踪一个 socket 和网络上被阻塞的每个请求的一些状态。在服务器上有一个单一的主事件循环,它调用 select 来处理所有被阻塞的套接字。当 select 返回时,服务器知道哪些请求可以进行了,因此对于每个请求,它调用应用程序逻辑中的存储状态。当应用程序需要再次使用网络时,它会将套接字连同新状态一起添加回“阻塞”池中。这里的状态可以是应用程序恢复它正在做的事情所需的任何东西: 一个要回调的 closure,或者一个 Promise。
从技术上讲,这些其实都可以用一个线程实现。这里不能谈论任何特定实现的细节,但是像 JavaScript
这样缺乏线程的语言也很好的遵循了这个模型。Node.js 更是将自己描述为“an event-driven JavaScript runtime, designed to build scalable network applications.”
事件驱动的服务器通常比纯粹基于 fork 或线程的服务器更好地利用 CPU 和内存。你可以为每个核心生成一个应用程序线程来并行处理请求。线程不会相互争夺 CPU,因为线程的数量等于内核的数量。当有请求可以进行时,线程永远不会空闲,非常高效。效率如此之高,以至于现在大家都使用这种方式来编写服务端代码。
从理论上讲,这听起来不错,但是如果你编写这样的应用程序代码,就会发现这是一场噩梦。。。具体是什么样的噩梦,取决于你所使用的语言和框架。在 JavaScript 中,异步函数通常返回一个 Promise,你给它附加回调。在 Java gRPC 中,你要处理的是 StreamObserver。如果你不小心,你最终会得到很多深度嵌套的“箭头代码”函数。如果你很小心,你就把函数和类分开了,混淆了你的控制流。不管怎样,你都是在 callback hell 里。
下面是一个 Java gRPC 官方教程 中的一个示例:
1 |
public void routeChat() throws Exception { |
上面代码官方的初学者教程,它不是一个完整的例子,发送代码是同步的,而接收代码是异步的。在 Java 中,你可能会为你的 HTTP 服务器、gRPC、数据库和其它任何东西处理不同的异步类型,你需要在所有这些服务器之间使用适配器,这很快就会变得一团糟。
同时这里如果使用锁也很危险,你需要小心跨网络调用持有锁。锁和回调也很容易犯错误。例如,如果一个同步方法调用一个返回 ListenableFuture 的函数,然后附加一个内联回调,那么这个回调也需要一个同步块,即使它嵌套在父方法内部。
终于到了我们的主角——goroutines。它是 Go 语言版本的线程。像它语言(比如:Java)中的线程一样,每个 gooutine 都有自己的堆栈。goroutine 可以与其它 goroutine 并行执行。与线程不同,goroutine 的创建成本非常低:它不绑定到 OS 线程上,它的堆栈开始非常小(初始只有 2 K),但可以根据需要增长。当你创建一个 goroutine 时,你实际上是在分配一个 closure,并在运行时将其添加到队列中。
在内部实现中,Go 的运行时有一组执行程序的 OS 线程(通常每个内核一个线程)。当一个线程可用并且一个 goroutine 准备运行时,运行时将这个 goroutine 调度到线程上,执行应用程序逻辑。如果一个运行例程阻塞了像 mutex 或 channel 这样的东西时,运行时将它添加到阻塞的运行 goroutine 集合中,然后将下一个就绪的运行例程调度到同一个 OS 线程上。
这也适用于网络:当一个线程程序在未准备好的套接字上发送或接收数据时,它将其 OS 线程交给调度器。这听起来是不是很熟悉?Go 的调度器很像事件驱动服务器中的主循环。除了仅仅依赖于 select 和专注于文件描述符之外,调度器处理语言中可能阻塞的所有内容。
你不再需要避免阻塞调用,因为调度程序可以有效地利用 CPU。可以自由地生成许多 goroutine(可以每个请求一个!),因为创建它们的成本很低,而且不会争夺 CPU,你不需要担心线程池和执行器服务,因为运行时实际上有一个大的线程池。
简而言之,你可以用干净的命令式风格编写简单的阻塞应用程序代码,就像在编写一个基于线程的服务器一样,但你保留了事件驱动服务器的所有效率优势,两全其美。这类代码可以很好地跨框架组合。你不需要 streamobserver 和 ListenableFutures 之间的这类适配器。
下面让我们看一下来自 Go gRPC 官方教程 的相同示例。可以发现这里的控制流比 Java 示例中的更容易理
解,因为发送和接收代码都是同步的。在这两个 goroutines 中,我们都可以在一个 for 循环中调用 stream.Recv 和stream.Send。不再需要回调、子类或执行器这些东西了。
1 |
stream, err := client.RouteChat(context.Background()) |
如何你使用 Java 这门语言,到目前为止,你要么必须生成数量不合理的线程,要么必须处理 Java 特有的回调地狱。令人高兴的是,JEP 444 中增加了 virtual threads,这看起来很像 Go 语言中的 goroutine。
创建虚拟线程的成本很低。JVM 将它们调度到平台线程(platform threads,内核中的真实线程)上。平台线程的数量是固定的,一般每个内核一个平台线程。当一个虚拟线程执行阻塞操作时,它会释放它的平台线程,JVM
可能会将另一个虚拟线程调度到它上面。与 gooutine 不同,虚拟线程调度是协作的: 虚拟线程在执行阻塞操作之前不会服从于调度程序。这意味着紧循环可以无限期地保持线程。目前不清楚这是实现限制还是有更深层次的问题。Go 以前也有这个问题,直到 1.14 才实现了完全抢占式调度(可见 GopherCon 2021)。
Java 的虚拟线程现在可以预览,预计在 JDK 21 中成为 stable(官方消息是预计 2023 年 9 月发布)状态。哈哈,很期待到时候能删除大量的 ListenableFutures。每当引入一种新的语言或运行时特性时,都会有一个漫长的迁移过渡期,个人认为 Java 生态系统在这方面还是过于保守了。
2024-12-21 13:36:04
大部分人在日常的业务开发中,其实很少去关注数据库的事务相关问题,基本上都是 CURD 一把梭。正好最近在看 MySQL 的相关基础知识,其中对于幻读问题之前一直没有理解深刻,今天就来聊聊「InnoDB 是如何解决幻读的」,话不多说,下面进入主题。
事务隔离是数据库处理的基础之一,是 ACID 中的 I
。在 MySQL 的 InnoDB 引擎中支持在 SQL:1992 标准中的四种事务隔离级别,如下图所示,其中 P1 表示脏读(Dirty read),P2 表示不可重复读(Dirty read),P3 表示幻读(Phantom)。
为什么需要定义这么多隔离呢?从上图中也能猜出一二了,InnoDB 提供多个隔离级别主要原因是:让使用者可以在多个事务同时进行更改和执行查询时微调性能与结果的可靠性、一致性和可再现性之间的平衡的设置。是一种性能与结果可靠性间的 trade off
。
在聊「InnoDB 解决幻读方式」前我们需要先了解幻读是什么,官方文档的描述如下:
A row that appears in the result set of a query, but not in the result set of an earlier query.
其中我加粗的「result set」是关键的地方,两次查询返回的是结果集,说明必须是一个范围查询操作。总结下,幻读就是:在同一个事务中,在前后两次查询相同范围时,两次查询得到的结果是不一致的。所以幻读会产生数据一致性问题。
为了解决上述的幻读问题,InnoDB 引入了两种锁,分别是「间隙锁」和「next-key 锁」。下面通过一个示例来描述这两种锁的作用分别是什么。假如存在一个这样的 B+ Tree 的索引结构,结构中有 4 个索引元素分别是:9527、9530、9535、9540。
此时当我们使用如下 SQL 通过主键索引查询一条记录,并且加上 X 锁(排它锁)时:
1 |
select * from user where id = 9527 for update; |
这时就会产生一个记录锁(也就是行锁),锁定 id = 9527
这个索引。
在被锁定的记录(这里是 id = 9527)的锁释放之前,其它事务无法对这条被锁定记录做任何操作。再回忆一下,前面说的幻读定义「在同一个事务中,在前后两次查询相同范围时,两次查询得到的结果是不一致」。注意,这里强调的是范围查询。
InnoDB 要解决幻读问题,就必须得保证在如果在一个事务中,通过如下这条语句进行锁定时:
1 |
select * from user where id > 9530 and id < 9535 for update; |
此时,另外一个语句再执行一如下这条 insert 语句时,需要被阻塞,直到上面这个获得锁的事务释放锁后才能执行。
1 |
insert into user(id, name, age) values(9533, 'Jack', 44); |
为此,InnoDB 引入了「间隙锁」,它的主要功能是锁定一段范围内的索引记录。比如上面查询 id > 9530 and id < 9535
的时候,对 B+ Tree 中的(9530,9535)这个开区间范围的索引加间隙锁。
在这种加了间隙锁的情况下,其它事务对这个区间的数据进行插入、更新、删除都会被锁住直到这个获取到锁的事务释放。
这种是在区间之间的情况,你可能想到另外的一种情况:锁定多个区间,如下的一条语句:
1 |
select * from user where id > 9530 for update; |
上面这条查询语句是针对 id > 9530
这个条件加锁,那么此时它需要锁定多个索引区间,所以在这种情况下 InnoDB 引入了「next-key 锁」机制。其实 next-key 锁的效果相当于间隙锁和记录锁的合集,记录锁锁定存在的记录行,间隙锁锁住记录行之间的间隙,而 next-key 锁它锁住的是两者之和。
在 InnoDB 中,每个数据行上的非唯一索引列上都会存在一把 next-key 锁,当某个事务持有该数据行的 next-key 锁时,会锁住一段左开右闭区间的数据。因此,当通过 id > 9530
这样一种范围查询加锁时,会加 next-key 锁,锁定区间是范围是:
(9530,9535] (9535,9540] (9540,+∞]
间隙锁(也叫 Gap 锁)和 next-key 锁的区别在于加锁的范围,间隙锁只锁定两个索引之间的引用间隙,而 next-key 锁会锁定多个索引区间,它包含「记录锁」和「间隙锁」。所以,当我们使用了范围查询,不仅仅命中了已存在的 Record 记录,还包含了 Gap 间隙。
虽然在 InnoDB 引擎中通过间隙锁和 next-key 锁的方式解决了幻读问题,但是加锁之后会影响到数据库的并发性能,因此,如果对性能要求较高的业务场景中,建议把隔离级别设置成 RC(READ COMMITTED),这个级别中不存在间隙锁,但是需要考虑到幻读问题会导致的数据一致性。
2022-11-06 12:51:47
首先需要明确的是 TCP 是一个可靠传输协议,它的所有特点最终都是为了这个可靠传输服务。在网上看到过很多文章讲 TCP 连接的三次握手
和断开连接的四次挥手
,但是都太过于理论,看完感觉总是似懂非懂。反复思考过后,觉得我自己还是偏工程型的人,要学习这些理论性的知识,最好的方式还是要通过实际案例来理解,这样才会具象深刻。本文通过 Wireshark 抓包来分析 TCP 三次握手
和四次挥手
,如果你也对这些理论感觉似懂非懂,那么强烈建议你也结合抓包实践来强化理解这些理论性的知识。
TCP 建立连接的三次握手是连接的双方协商确认一些信息(Sequence number、Maximum Segment Size、Window Size 等),Sequence number 有两个作用:一个是 SYN 标识位为 1 时作为初始序列号(ISN),则实际第一个数据字节的序列号和相应 ACK 中的确认号就是这个序列号加 1;另一个是 SYN 标识位为 0 时,则是当前会话的 segment(传输层叫 segment,网络层叫 packet,数据链路层叫 frame)的第一个数据字节的累积序列号。Maximum Segment Size 简称 MSS,表示最大一个 segment 中能传输的信息(不含 TCP、IP 头部)。Window Size 表示发送方接收窗口的大小。下面看看我在本地访问博客 mghio 的三次握手过程:
图中三个小红框表示与服务器建立连接的三次握手。
到这一步,client 端的 60469 端口已经是 ESTABLISHED 状态了。
可以看到,其实三次握手的核心目的就是双方互相告知对象自己的 Sequence number,蓝框是 client 端的初始 Sequence number 和 client 端回复的 ACK,绿框是 server 端的初始 Sequence number 和 client 端回复的 ACK。这样协商好初始 Sequence number 后,发送数据包时发送端就可以判断丢包和进行丢包重传了。
三次握手还有一个目的是协商一些信息(上图中黄色方框是 Maximum Segment Size,粉色方框是 Window Size)。
到这里,就可以知道平常所说的建立TCP连接
本质是为了实现 TCP 可靠传输做的前置准备工作,实际上物理层并没有这个连接在那里。TCP 建立连接之后时拥有和维护一些状态信息,这个状态信息就包含了 Sequence number、MSS、Window Size 等,TCP 握手就是协商出来这些初始值。而这些状态才是我们平时所说的 TCP 连接的本质。因为这个太重要了,我还要再次强调一下,TCP 是一个可靠传输协议,它的所有特点最终都是为了这个可靠传输服务。
下面再来看看,当关闭浏览器页面是发生断开连接的四次挥手过程:
相信你已经发现了,上图抓包抓到的不是四次挥手,而是三次挥手,这是为何呢?
这是由于 TCP 的时延机制(因为系统内核并不知道应用能不能立即关闭),当被挥手端(这里是 server 的 443 端口)第一次收到挥手端(这里是 client 的 63612 端口)的 FIN 请求时,并不会立即发送 ACK,而是会经过一段延迟时间后再发送,但是此时被挥手端也没有数据发送,就会向挥手端发送 FIN 请求,这里就可能造成被挥手端发送的 FIN 与 ACK 一起被挥手端收到,导致出现第二、三次挥手合并为一次的现象,也就最终呈现出“三次挥手”的情况。
断开连接四次挥手分为如下四步(假设没有出现挥手合并的情况):
下面是 TCP 连接流转状态图(其中 CLOSED 状态是虚拟的,实际上并不存在),这个图很重要,记住这个图后基本上所有的 TCP 网络问题就可以解决。
其中比较难以理解的是 TIME_WAIT 状态,主动关闭的那一端会经历这个状态。这一端停留在这个状态的最长时间是 Maximum segment lifetime(MSL)的 2 倍,大部分时候被简称之为 2MSL。存在 TIME_WAIT 状态有如下两个原因:
嘿嘿,这是个经典的面试题,其实大部分人都背过挥手是四次的原因:因为 TCP 是全双工(双向)的,所以回收需要四次……。但是再反问下:握手也是双向的,但是为什么是只要三次呢?
网上流传的资料都说 TCP 是双向的,所以回收需要四次,但是握手也是双向(握手双方都在告知对方自己的初始 Sequence number),那么为什么就不用四次握手呢?所以凡事需要多问几个为什么,要有探索和怀疑精神。
你再仔细回看上面三次握手的第二步(SYN + ACK),其实是可以拆分为两步的:第一步回复 ACK,第二步再发 SYN 也是完全可以的,只是效率会比较低,这样的话三次握手不也变成四次握手了。
看起来四次挥手主要是收到第一个 FIN 包后单独回复了一个 ACK 包这里多了一次,如果能像握手那样也回复 FIN + ACK 那么四次挥手也就变成三次了。这里再贴一下上面这个挥手的抓包图:
这个图中第二个红框就是 server 端回复的 FIN + ACK 包,这样四次挥手变成三次了(如果一个包算一次的话)。这里使用四次挥手原因主要是:被动关闭端在收到 FIN 后,知道主动关闭端要关闭了,然后系统内核层会通知应用层要关闭,此时应用层可能还需要做些关闭前的准备工作,可能还有数据没发送完,所以系统内核先回复一个 ACK 包,然后等应用层准备好了主动调 close 关闭时再发 FIN 包。
而握手过程中就没有这个准备过程了,所以可以立即发送 SYN + ACK(在这里的两步合成一步了,提高效率)。挥手过程中系统内核在收到对方的 FIN 后,只能 ACK,不能主动替应用来 FIN,因为系统内核并不知道应用能不能立即关闭。
TCP 是一个很复杂的协议,为了实现可靠传输以及处理各种网络传输中的 N 多问题,有一些很经典的解决方案,比如其中的网络拥塞控制算法、滑动窗口、数据重传等。强烈建议你去读一下 rfc793 和 TCP/IP 详解 卷1:协议 这本书。
如果你是那些纯看理论就能掌握好一门技能,然后还能举三反一的人,那我很佩服你;如果不是,那么学习理论知识注意要结合实践来强化理解理论,要经过反反复复才能比较好地掌握一个知识,讲究技巧,必要时要学会通过工具来达到目的。
最后 TCP 所有特性基本上核心都是为了实现可靠传输这个目标来服务的,然后有一些是出于优化性能的目的。
2022-06-05 16:29:44
在 Spring 框架中有很多实用的功能,不需要写大量的配置代码,只需添加几个注解即可开启。 其中一个重要原因是那些 @EnableXXX 注解,它可以让你通过在配置类加上简单的注解来快速地开启诸如事务管理(@EnableTransactionManagement)、Spring MVC(@EnableWebMvc)或定时任务(@EnableScheduling)等功能。这些看起来简单的注解语句提供了很多功能,但它们的内部机制从表面上看却不太明显。 一方面,对于使用者来说用这么少的代码获得这么多实用的功能是很好的,但另一方面,如果你不了解某个东西的内部是如何工作的,就会使调试和解决问题更加困难。
Spring 框架中那些 @EnableXXX 注解的设计目标是允许用户用最少的代码来开启复杂使用的功能。 此外,用户必须能够使用简单的默认值,或者允许手动配置该代码。最后,代码的复杂性要向框架使用者隐藏掉。 简而言之,让使用者设置大量的 Bean,并选择性地配置它们,而不必知道这些 Bean 的细节(或真正被设置的内容)。下面来看看具体的几个例子:
首先要知道的是,@EnableXXX 注解并不神奇。实际上在 BeanFactory 中并不知道这些注解的具体内容,而且在 BeanFactory 类中,核心功能和特定注解(如 @EnableWebMvc)或它们所存放的 jar 包(如 spring-web)之间没有任何依赖关系。 让我们看一下 @EnableScheduling,下面看看它是如何工作的。 定义一个 SchedulingConfig 配置类,如下所示:
1 |
|
上面的内容没有什么特别之处。只是一个用 @EnableScheduling 注释的标准 Java 配置。@EnableScheduling 让你以设定的频率执行某些方法。例如,你可以每 10 分钟运行 BankService.transferMoneyToMghio()。 @EnableScheduling 注解源码如下:
1 |
(ElementType.TYPE) |
上面的 EnableScheduling 注解,我们可以看到它只是一个标准的类级注解(@Target/@Retention),应该包含在 JavaDocs 中(@Documented),但是它有一个 Spring 特有的注解(@Import)。 @Import 是将一切联系起来的关键。 在这种情况下,由于我们的 SchedulingConfig 被注解为 @EnableScheduling,当 BeanFactory 解析文件时(内部是ConfigurationClassPostProcessor 在解析它),它也会发现 @Import(SchedulingConfiguration.class) 注解,它将导入该值中定义的类。 在这个注解中,就是 SchedulingConfiguration。
这里导入是什么意思呢?在这种情况下,它只是被当作另一个 Spring Bean。 SchedulingConfiguration 实际上被注解为@Configuration,所以 BeanFactory 会把它看作是另一个配置类,所有在该类中定义的 Bean 都会被拉入你的应用上下文,就像你自己定义了另一个 @Configuration 类一样。 如果我们检查 SchedulingConfiguration,我们可以看到它只定义了一个Bean(一个Post Processor),它负责我们上面描述的调度工作,源码如下:
1 |
|
也许你会问,如果想配置 SchedulingConfiguration 中定义的 bean 呢? 这里也只是在处理普通的Bean。 所以你对其它 Bean 所使用的机制也适用于此。 在这种情况下,ScheduledAnnotationBeanPostProcessor 使用一个标准的 Spring Bean 生命周期(postProcessAfterInitialization)来发现应用程序上下文何时被刷新。 当符合条件时,它会检查是否有任何 Bean 实现了 SchedulingConfigurer,如果有,就使用这些 Bean 来配置自己。 其实这一点并不明细(在 IDE 中也不太容易找到),但它与 BeanFactory 是完全分离的,而且是一个相当常见的模式,一个 Bean 被用来配置另一个 Bean。 而现在我们可以把所有的点连接起来,它(在某种程度上)很容易找到(你可以 Google 一下文档或阅读一下 JavaDocs)。
在上一个示例中,我们讨论了像 @EnableScheduling 这样的注解如何使用 @Import 来导入另一个 @Configuration 类并使其所有的 Bean 对你的应用程序可用(和可配置)。但是如果你想根据某些配置加载不同的 Bean 集,会发生什么呢? @EnableTransactionManagement 就是一个很好的例子。TransactioConfig 定义如下:
1 |
|
再一次,上面没有什么特别之处。只是一个用@EnableTransactionManagement注释的标准Java配置。唯一与之前的例子有些不同的是,用户为注释指定了一个参数(mode=AdviceMode.ASPECTJ)。 @EnableTransactionManagement注解本身看起来像这样。
1 |
(ElementType.TYPE) |
和前面一样,一个相当标准的注解,尽管这次它有一些参数。 然而,正如前文提到,@Import 注解是将一切联系在一起的关键,这一点再次得到证实。 但区别在于,这次我们导入的是 TransactionManagementConfigurationSelector 这个类,通过源码可以发现,其实它不是一个被 @Configuration 注解的类。 TransactionManagementConfigurationSelector 是一个实现ImportSelector 的类。 ImportSelector 的目的是让你的代码选择在运行时加载哪些配置类。 它有一个方法,接收关于注解的一些元数据,并返回一个类名数组。 在这种情况下,TransactionManagementConfigurationSelector 会查看模式并根据模式返回一些类。其中的 selectImports 方法源码如下:
1 |
|
这些类中的大多数是 @Configuration(例如 ProxyTransactionManagementConfiguration),通过前文介绍我们知道它们会像前面一样工作。 对于 @Configuration 类,它们被加载和配置的方式与我们之前看到的完全一样。 所以简而言之,我们可以使用 @Import 和 @Configuration 类来加载一套标准的 Bean,或者使用 @Import 和 ImportSelector 来加载一套在运行时决定的 Bean。
@Import 支持的最后一种情况,即当你想直接处理 BeanRegistry(工厂)时。如果你需要操作Bean Factory或者在Bean定义层处理Bean,那么这种情况就适合你,它与上面的情况非常相似。 你的 AspectJProxyConfig 可能看起来像。
1 |
|
再一次,上面定义没有什么特别的东西。只是一个用 @EnableAspectJAutoProxy 注释的标准 Java 配置。 下面是@EnableAspectJAutoProxy 的源代码。
1 |
(ElementType.TYPE) |
和前面一样,@Import 是关键,但这次它指向 AspectJAutoProxyRegistrar,它既没有 @Configuration 注解,也没有实现 ImportSelector 接口。 这次使用的是实现了 ImportBeanDefinitionRegistrar。 这个接口提供了对 Bean 注册中心(Bean Registry)和注解元数据的访问,因此我们可以在运行时根据注解中的参数来操作 Bean 注册表。 如果你仔细看过前面的示例,你可以看到我们忽略的类也是 ImportBeanDefinitionRegistrar。 在 @Configuration 类不够用的时候,这些类会直接操作 BeanFactory。
所以现在我们已经涵盖了 @EnableXXX 注解使用 @Import 将各种 Bean 引入你的应用上下文的所有不同方式。 它们要么直接引入一组 @Configuration 类,这些类中的所有 Bean 都被导入到你的应用上下文中。 或者它们引入一个 ImportSelector 接口实现类,在运行时选择一组 @Configuration 类并将这些 Bean 导入到你的应用上下文中。 最后,他们引入一个ImportBeanDefinitionRegistrars,可以直接与 BeanFactory 在 BeanDefinition 级别上合作。
总的来说,个人认为这种将 Bean 导入应用上下文的方法很好,因为它使框架使用者的使用某个功能非常容易。不幸的是,它模糊了如何找到可用的选项以及如何配置它们。 此外,它没有直接利用 IDE 的优势,所以很难知道哪些 Bean 正在被创建(以及为什么)。 然而,现在我们知道了 @Import 注解,我们可以使用 IDE 来挖掘一下每个注解及其相关的配置类,并了解哪些 Bean 正在被创建,它们如何被添加到你的应用上下文中,以及如何配置它们。 希望对你有帮助~