This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,181 @@
<audio id="audio" title="01 | 追古溯源TCP/IP和Linux是如何改变世界的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ac/c8/acd295377e01afc19716ab214423ebc8.mp3"></audio>
你好,我是盛延敏。今天是网络编程课程的第一课,我想你一定满怀热情,期望快速进入到技术细节里,了解那些你不熟知的编程技能。而今天我却想和你讲讲历史,虽然这些事情看着不是“干货”,但它可以帮助你理解网络编程中各种技术的来龙去脉。
你我都是程序员,说句实在话,我们正处于一个属于我们的时代里,我们也正在第一线享受着这个时代的红利。在我看来,人类历史上还从来没有一项技术可以像互联网一样深刻地影响人们生活的方方面面。
而具体到互联网技术里有两件事最为重要一个是TCP/IP协议它是万物互联的事实标准另一个是Linux操作系统它是推动互联网技术走向繁荣的基石。
今天我就带你穿越时间的走廊看一看TCP/IP事实标准和Linux操作系统是如何一步一步发展到今天的。
## TCP发展历史
一般来说我们认为互联网起源于阿帕网ARPANET
最早的阿帕网还是非常简陋的网络控制协议Network Control Protocol缩写NCP是阿帕网中连接不同计算机的通信协议。
在构建阿帕网ARPANET之后其他数据传输技术的研究又被摆上案头。NCP诞生两年后NCP的开发者温特·瑟夫Vinton Cerf和罗伯特·卡恩Robert E. Kahn一起开发了一个阿帕网的下一代协议并在1974年发表了以分组、序列化、流量控制、超时和容错等为核心的一种新型的网络互联协议一举奠定了TCP/IP协议的基础。
### OSI &amp; TCP/IP
在这之后TCP/IP逐渐发展。咱们话分两头说一头是一个叫ISO的组织发现计算机设备的互联互通是一个值得研究的新领域于是这个组织出面和众多厂商游说“我们一起定义出一个网络互联互通的标准吧这样大家都遵守这个标准一起把这件事情做大大家就有钱赚了”。众多厂商觉得可以啊于是ISO组织就召集了一帮人认真研究起了网络互联这件事情还真的搞出来一个非常强悍的标准这就是OSI参考模型。这里我不详细介绍OSI参考模型了你可以阅读[罗剑锋老师的专栏](https://time.geekbang.org/column/article/99286),他讲得很好。
这个标准发布的时候已经是1984年了有点尴尬的是OSI搞得是很好大家也都很满意不过等它发布的时候ISO组织却惊讶地发现满世界都在用一个叫做TCP/IP协议栈的东西而且跟OSI标准半毛钱关系都没有。
这就涉及到了另一头——TCP/IP的发展。
事实上我在前面提到的那两位牛人卡恩和瑟夫一直都在不遗余力地推广TCP/IP。当然TCP/IP的成功也不是偶然的而是综合了几个因素后的结果
1. TCP/IP是免费或者是少量收费的这样就扩大了使用人群
1. TCP/IP搭上了UNIX这辆时代快车很快推出了基于套接字socket的实际编程接口
1. 这是最重要的一点TCP/IP来源于实际需求大家都在翘首盼望出一个统一标准可是在此之前实际的问题总要解决啊TCP/IP解决了实际问题并且在实际中不断完善。
回过来看OSI的七层模型定得过于复杂并且没有参考实现在一定程度上阻碍了普及。
不过OSI教科书般的层次模型对后世的影响很深远一般我们说的4层、7层也是遵从了OSI模型的定义分别指代传输层和应用层。
我们说TCP/IP的应用层对应了OSI的应用层、表示层和会话层TCP/IP的网络接口层对应了OSI的数据链路层和物理层。
<img src="https://static001.geekbang.org/resource/image/cb/b4/cb34e0e3b7769498ea703fe6231201b4.png" alt="">
## UNIX操作系统发展历史
前面我们提到了TCP/IP协议的成功离不开UNIX操作系统的发展。接下来我们就看下UNIX操作系统是如何诞生和演变的。
下面这张图摘自[维基百科](https://en.wikipedia.org/wiki/File:Unix_timeline.en.svg)它将UNIX操作系统几十年的发展历史表述得非常清楚。
<img src="https://static001.geekbang.org/resource/image/a6/0f/a68c3b9b267574ea2f309ed6a4e0de0f.png" alt=""><br>
UNIX的各种版本和变体都起源于在PDP-11系统上运行的UNIX分时系统第6版1976年和第7版1979年它们通常分别被称为V6和V7。这两个版本是在贝尔实验室以外首先得到广泛应用的UNIX系统。
这张图画得比较概括我们主要从这张图上看3个分支
- 图上标示的Research橘黄色部分是由AT&amp;T贝尔实验室不断开发的UNIX研究版本从此引出UNIX分时系统第8版、第9版终止于1990年的第10版10.5。这个版本可以说是操作系统界的少林派。天下武功皆出少林世上UNIX皆出自贝尔实验室。
- 图中最上面所标识的操作系统版本是加州大学伯克利分校BSD研究出的分支从此引出4.xBSD实现以及后面的各种BSD版本。这个可以看做是学院派。在历史上学院派有力地推动了UNIX的发展包括我们后面会谈到的socket套接字都是出自此派。
- 图中最下面的那一个部分是从AT&amp;T分支的商业派致力于从UNIX系统中谋取商业利润。从此引出了System III和System V被称为UNIX的商用版本还有各大公司的UNIX商业版。
下面这张图也是源自维基百科将UNIX的历史表达得更为详细。
<img src="https://static001.geekbang.org/resource/image/df/bb/df2b6d77a0a46e3d9b068f6d517a15bb.png" alt=""><br>
一个基本事实是网络编程套接字接口最早是在BSD 4.2引入的这个时间大概是1983年几经演变后成为了事实标准包括System III/V分支也吸收了这部分能力在上面这张大图上也可以看出来。
其实这张图也说明了一个很有意思的现象BSD分支、System III/System V分支、正统的UNIX分时系统分支都是互相借鉴的也可以说是互相“抄袭”吧。但如果这样发展下去互相不买对方的账导致上层的应用程序在不同的UNIX版本间不能很好地兼容这该怎么办这里先留一个疑问你也可以先想一想稍后我会给你解答。
下面我再介绍几个你耳熟能详的重要UNIX玩家。
### SVR 4
SVR4UNIX System V Release 4是AT&amp;T的UNIX系统实验室的一个商业产品。它基本上是一个操作系统的大杂烩这个操作系统之所以重要是因为它是System III/V分支各家商业化UNIX操作系统的“先祖”包括IBM的AIX、HP的HP-UX、SGI的IRIX、Sun后被Oracle收购的Solaris等等。
### Solaris
Solaris是由Sun Microsystems现为Oracle开发的UNIX系统版本它基于SVR4并且在商业上获得了不俗的成绩。2005年Sun Microsystems开源了Solaris操作系统的大部分源代码作为OpenSolaris开放源代码操作系统的一部分。相对于Linux这个开源操作系统的进展比较一般。
### BSD
BSDBerkeley Software Distribution我们上面已经介绍过了是由加州大学伯克利分校的计算机系统研究组CSRG研究开发和分发的。4.2BSD于1983年问世其中就包括了网络编程套接口相关的设计和实现4.3BSD则于1986年发布正是由于TCP/IP和BSD操作系统的完美拍档才有了TCP/IP逐渐成为事实标准的这一历史进程。
### macOS X
用mac笔记本的同学都有这样的感觉macOS提供的环境和Linux环境非常像很多代码可以在macOS上以接近线上Linux真实环境的方式运行。
有心的同学应该想过,这背后有一定的原因。
答案其实很简单macOS和Linux的血缘是相近的它们都是UNIX基础上发展起来的或者说它们各自就是一个类UNIX的系统。
macOS系统又被称为Darwin它已被验证过就是一个UNIX操作系统。如果打开Mac系统的socket.h头文件定义你会明显看到macOS系统和BSD千丝万缕的联系说明这就是从BSD系统中移植到macOS系统来的。
## Linux
我们把Linux操作系统单独拿出来讲是因为它实在太重要了全世界绝大部分数据中心操作系统都是跑在Linux上的就连手机操作系统Android也是一个被“裁剪”过的Linux操作系统。
Linux操作系统的发展有几个非常重要的因素这几个因素叠加在一起造就了如今Linux非凡的成就。我们一一来看。
### UNIX的出现和发展
第一个就是UNIX操作系统要知道Linux操作系统刚出世的时候 4.2/4.3 BSD都已经出现快10年了这样就为Linux系统的发展提供了一个方向而且Linux的开发语言是C语言C语言也是在UNIX开发过程中发明的一种语言。
### POSIX标准
UNIX操作系统虽然好但是它的源代码是不开源的。那么如何向UNIX学习呢这就要讲一下POSIX标准了POSIXPortable Operating System Interface for Computing Systems这个标准基于现有的UNIX实践和经验描述了操作系统的调用服务接口。有了这么一个标准Linux完全可以去实现并兼容它这从最早的Linux内核头文件的注释可见一斑。
这个头文件里定义了一堆POSIX宏并有一句注释“嗯也许只是一个玩笑不过我正在完成它。”
```
# ifndef _UNISTD_H
# define _UNISTD_H
/* ok, this may be a joke, but I'm working on it */
# define _POSIX_VERSION 198808L
# define _POSIX_CHOWN_RESTRICTED /* only root can do a chown (I think..) */
/* #define _POSIX_NO_TRUNC*/ /* pathname truncation (but see in kernel) */
# define _POSIX_VDISABLE '\0' /* character to disable things like ^C */
/*#define _POSIX_SAVED_IDS */ /* we'll get to this yet */
/*#define _POSIX_JOB_CONTROL */ /* we aren't there quite yet. Soon hopefully */
```
POSIX相当于给大厦画好了图纸给Linux的发展提供了非常好的指引。这也是为什么我们的程序在macOS和Linux可以兼容运行的原因因为大家用的都是一张图纸只不过制造商不同程序当然可以兼容运行了。
### Minix操作系统
刚才提到了UNIX操作系统不开源的问题那么有没有一开始就开源的UNIX操作系统呢这里就要提到Linux发展的第三个机遇Minix操作系统它在早期是Linux发展的重要指引。这个操作系统是由一个叫做安迪·塔能鲍姆Andy Tanenbaum的教授开发的他的本意是用来做UNIX教学的甚至有人说如果Minix操作系统也完全走社区开放的道路那么未必有现在的Linux操作系统。当然这些话咱们就权当作是马后炮了。Linux早期从Minix中借鉴了一些思路包括最早的文件系统等。
### GNU
Linux操作系统得以发展还有一个非常重要的因素那就是GNUGNUs NOT UNIX它的创始人就是鼎鼎大名的理查·斯托曼Richard Stallman。斯托曼的想法是设计一个完全自由的软件系统用户可以自由使用自由修改这些软件系统。
GNU为什么对Linux的发展如此重要呢事实上GNU之于Linux是要早很久的GNU在1984年就正式诞生了。最开始斯托曼是想开发一个类似UNIX的操作系统的。
>
<p>From CSvax:pur-ee:inuxc!ixn5c!ihnp4!houxm!mhuxi!eagle!mit-vax!mit-eddie!RMS@ MIT-OZ<br>
From: RMS% MIT-OZ@ mit-eddie<br>
Newsgroups: net.unix-wizards,net.usoft<br>
Subj ect: new UNIX implementation<br>
Date: Tue, 27-Sep-83 12:35:59 EST<br>
Organization: MIT AI Lab, Cambridge, MA<br>
Free Unix!<br>
Starting this Thanksgiving I am going to write a complete Unix-compatible software system called GNU (for Gnus Not Unix), and give it away free to everyone who can use it. Contributions of time,money, programs and equipment are greatly needed.<br>
To begin with, GNU will be a kernel plus all the utilities needed to write and run C programs: editor, shell, C compiler, linker, assembler, and a few other things. After this we will add a text formatter, a YACC, an Empire game, a spreadsheet, and hundreds of other things. We hope to supply, eventually, everything useful that normally comes with a Unix system, and anything else useful, including on-line and hardcopy documentation.<br>
</p>
在这个设想宏大的GNU计划里包括了操作系统内核、编辑器、shell、编译器、链接器和汇编器等等每一个都是极其难啃的硬骨头。
不过斯托曼可是个牛人单枪匹马地开发出世界上最牛的编辑器Emacs继而组织和成立了自由软件基金会the Free Software Foundation - FSF
GNU在自由软件基金会统一组织下相继推出了编译器GCC、调试器GDB、Bash Shell等运行于用户空间的程序。正是这些软件为Linux 操作系统的开发创造了一个合适的环境比如编译器GCC、Bash Shell就是Linux能够诞生的基础之一。
你有没有发现GNU独缺操作系统核心
实际上1990年自由软件基金会开始正式发展自己的操作系统Hurd作为GNU项目中的操作系统。不过这个项目再三耽搁1991年Linux出现1993年FreeBSD发布这样GNU的开发者开始转向于Linux或FreeBSD其中Linux成为更常见的GNU软件运行平台。
斯托曼主张Linux操作系统使用了许多GNU软件正式名应为GNU/Linux但没有得到Linux社群的一致认同形成著名的GNU/Linux命名争议。
GNU是这么解释为什么应该叫GNU/Linux的“大多数基于Linux内核发布的操作系统基本上都是GNU操作系统的修改版。我们从1984 年就开始编写GNU 软件要比Linus开始编写它的内核早许多年而且我们开发了系统的大部分软件要比其它项目多得多我们应该得到公平对待。”
从这段话里我们可以知道GNU和GNU/Linux互相造就了对方没有GNU当然没有Linux不过没有LinuxGNU也不可能大发光彩。
在开源的世界里,也会发生这种争名夺利的事情,我们也不用觉得惊奇。
## 操作系统对TCP/IP的支持
讲了这么多操作系统的内容我们再来看下面这张图。图中展示了TCP/IP在各大操作系统的演变历史。可以看到即使是大名鼎鼎的Linux以及90年代大发光彩的Windows操作系统在TCP/IP网络这块也只能算是一个后来者。
<img src="https://static001.geekbang.org/resource/image/0f/e1/0f783e74927d70794421cf5983f22ae1.png" alt="">
## 总结
这是我们专栏的第一讲我没有直接开始讲网络编程而是对今天互联网技术的基石TCP和Linux进行了简单的回顾。通过这样的回顾熟悉历史可以指导我们今后学习的方向在后面的章节中我们都将围绕Linux下的TCP/IP程序设计展开。
最后你不妨思考一下Linux TCP/IP网络协议栈最初的实现“借鉴”了多少BSD的实现呢Linux到底是不是应该被称为GNU/Linux呢
欢迎你在评论区写下你的思考我会和你一起讨论这些问题。如果这篇文章帮你厘清了TCP/IP和Linux的发展脉络欢迎把它分享给你的朋友或者同事。

View File

@@ -0,0 +1,151 @@
<audio id="audio" title="02 | 网络编程模型:认识客户端-服务器网络模型的基本概念" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6a/0e/6afd59f889f555ea7f7a5693d942c70e.mp3"></audio>
你好我是盛延敏。上一讲我们学习了TCP/IP的创建和历史以及Linux操作系统的建立和发展相信你对网络编程这棵大树已经有了一个宏观上的认识那么今天我们再往前走几步近距离看看这棵大树的细枝末节到底是怎样的。
从哪里开始呢?从网络编程的基本概念开始说起吧。
## 客户端-服务器网络编程模型
在谈论网络编程时,我们首先需要建立一个概念,也就是我们今天的主题“客户端-服务器”。
拿我们常用的网络购物来说,我们在手机上的每次操作,都是作为客户端向服务器发送请求,并收到响应的例子。
这个过程具体阐释如下:
<img src="https://static001.geekbang.org/resource/image/78/83/78e415180d2946c418485d30f3f78f83.png" alt="">
1. 当一个客户端需要服务时,比如网络购物下单,它会向服务器端发送一个请求。注意,这个请求是按照双方约定的格式来发送的,以便保证服务器端是可以理解的;
1. 服务器端收到这个请求后,会根据双方约定的格式解释它,并且以合适的方式进行操作,比如调用数据库操作来创建一个购物单;
1. 服务器端完成处理请求之后,会给客户端发送一个响应,比如向客户端发送购物单的实际付款额,然后等待客户端的下一步操作;
1. 客户端收到响应并进行处理,比如在手机终端上显示该购物单的实际付款额,并且让用户选择付款方式。
在网络编程中,具体到客户端-服务器模型时我们经常会考虑是使用TCP还是UDP其实它们二者的区别也很简单TCP中连接是谁发起的在UDP中报文是谁发送的。在TCP通信中建立连接是一个非常重要的环节。区别出客户端和服务器本质上是因为二者编程模型是不同的。
服务器端需要在一开始就监听在一个众所周知的端口上,等待客户端发送请求,一旦有客户端连接建立,服务器端就会消耗一定的计算机资源为它服务,服务器端是需要同时为成千上万的客户端服务的。如何保证服务器端在数据量巨大的客户端访问时依然能维持效率和稳定,这也是我们讲述高性能网络编程的目的。
客户端相对来说更为简单,它向服务器端的监听端口发起连接请求,连接建立之后,通过连接通路和服务器端进行通信。
**还有一点需要强调的是无论是客户端还是服务器端它们运行的单位都是进程process而不是机器**。一个客户端比如我们的手机终端同一个时刻可以建立多个到不同服务器的连接比如同时打游戏上知乎逛天猫而服务器端更是可能在一台机器上部署运行了多个服务比如同时开启了SSH服务和HTTP服务。
## IP和端口
正如寄信需要一个地址一样在网络世界里同样也需要地址的概念。在TCP/IP协议栈中IP用来表示网络世界的地址。
前面我们提到了,在一台计算机上是可以同时存在多个连接的,那么如何区分出不同的连接呢?
这里就必须提到端口这个概念。我们拿住酒店举例子酒店的地址是唯一的每间房间的号码是不同的类似的计算机的IP地址是唯一的每个连接的端口号是不同的。
端口号是一个16位的整数最多为65536。当一个客户端发起连接请求时客户端的端口是由操作系统内核临时分配的称为临时端口然而前面也提到过服务器端的端口通常是一个众所周知的端口。
一个连接可以通过客户端-服务器端的IP和端口唯一确定这叫做套接字对按照下面的四元组表示
```
clientaddr:clientport, serveraddr: serverport)
```
下图表示了一个客户端-服务器之间的连接:
<img src="https://static001.geekbang.org/resource/image/54/2a/543b5488f9422558069df507cfaa462a.png" alt="">
## 保留网段
一个比较常见的现象是我们所在的单位或者组织普遍会使用诸如10.0.x.x或者192.168.x.x这样的IP地址你可能会纳闷这样的IP到底代表了什么呢不同的组织使用同样的IP会不会导致冲突呢?
背后的原因是这样的国际标准组织在IPv4地址空间里面专门划出了一些网段这些网段不会用做公网上的IP而是仅仅保留作内部使用我们把这些地址称作保留网段。
下表是三个保留网段其可以容纳的计算机主机个数分别是16777216个、1048576个和65536个。
<img src="https://static001.geekbang.org/resource/image/80/ef/8062576bcd515e1c84cec960e4796fef.png" alt=""><br>
在详细讲述这个表格之前,我们需要首先了解一下子网掩码的概念。
## 子网掩码
在网络IP划分的时候我们需要区分两个概念。
第一是网络network的概念直观点说它表示的是这组IP共同的部分比如在192.168.1.1~192.168.1.255这个区间里它们共同的部分是192.168.1.0。
第二是主机host的概念它表示的是这组IP不同的部分上面的例子中1~255就是不同的那些部分表示有255个可用的不同IP。
例如IPv4地址192.0.2.12,我们可以说前面三个 bytes 是子网,最后一个 byte 是 host或者换个方式我们能说 host 为8 位子网掩码为192.0.2.0/24255.255.255.0)。
有点晕?别着急,接下来要讲的是一些基本概念。
很久很久以前有子网subnet的分类在这里一个IPv4地址的第一个前两个或前三个 字节是属于网络的一部分。
如果你很幸运地可以拥有一个字节的网络,而另外三个字节是 host 地址那在你的网络里你有价值三个字节也就是24个比特的主机地址这是什么概念呢 2的24次方大约是一千六百万个地址左右。这是一个“Class A”A 类)网络。
<img src="https://static001.geekbang.org/resource/image/80/ef/8062576bcd515e1c84cec960e4796fef.png" alt=""><br>
我们再来重新看一下这张表格表格第一行就是这样的一个A类网络10是对应的网络字节部分主机的字节是3我们将一个字节的子网记作255.0.0.0。
相对的“Class B”B 类)的网络,网络有两个字节,而 host 只有两个字节也就是说拥有的主机个数为65536。“Class C”C 类)的网络,网络有三个 字节,而 host 只有一个 字节也就是说拥有的主机个数为256。
网络地址位数由子网掩码Netmask决定你可以将IP地址与子网掩码进行“位与”操作就能得到网络的值。子网掩码一般看起来像是 255.255.255.0二进制为11111111.11111111.11111111.00000000比如你的IP是192.0.2.12使用这个子网掩码时你的网络就会是192.0.2.12与255.255.255.0所得到的值192.0.2.0192.0.2.0就是这个网络的值。
子网掩码能接受任意个位而不单纯是上面讨论的816或24个比特而已。所以你可以有一个子网掩码255.255.255.252二进制位11111111.11111111.11111111.11111100这个子网掩码能切出一个30个位的网络以及2个位的主机这个网络最多有四台 host。为什么是4台host呢因为变化的部分只有最后两位所有的可能为2的2次方即4台host。
注意,子网掩码的格式永远都是二进制格式:前面是一连串的 1后面跟着一连串的 0。
不过一大串的数字会有点不好用,比如像 255.192.0.0 这样的子网掩码人们无法直观地知道有多少个1多少个0后来人们发明了新的办法你只需要将一个斜线放在IP地址后面接着用一个十进制的数字用以表示网络的位数类似这样192.0.2.12/30, 这样就很容易知道有30个1 2个0所以主机个数为4。
相信这个时候再去看保留网段,你应该能理解表格里的内容了。这里就不再赘述。
## 全球域名系统
如果每次要访问一个服务都要记下这个服务对应的IP地址无疑是一种枯燥而繁琐的事情就像你要背下200多个好友的电话号码一般无聊。
此时你应该知道我将要表达什么。对的正如电话簿记录了好友和电话的对应关系一样域名DNS也记录了网站和IP的对应关系。
全球域名按照从大到小的结构,形成了一棵树状结构。实际访问一个域名时,是从最底层开始写起,例如 [www.google.com](http://www.google.com,)[www.tinghua.edu.cn](http://www.tinghua.edu.cn)等。
<img src="https://static001.geekbang.org/resource/image/23/be/23dc0a68d6016b71365e62879a3a6cbe.jpg" alt="">
## 数据报和字节流
尽管名称是TCP/IP协议栈但是从上一讲关于OSI和TCP/IP协议栈的对比中我们看到传输层其实是有两种协议的一种是大家广为熟悉的TCP 而另一种就是UDP。
TCP又被叫做字节流套接字Stream Socket注意我们这里先引入套接字socket套接字socket在后面几讲中将被反复提起因为它实际上是网络编程的核心概念。当然UDP也有一个类似的叫法, 数据报套接字Datagram Socket一般分别以“SOCK_STREAM”与“SOCK_DGRAM”分别来表示TCP和UDP套接字。
Datagram Sockets 有时称为“无连接的sockets”connectionless sockets
Stream sockets 是可靠的、双向连接的通讯串流。比如以“1-2-3”的顺序将字节流输出到套接字上它们在另一端一定会以“1-2-3”的顺序抵达而且不会出错。
这种高质量的通信是如何办到的呢这就是由TCPTransmission Control Protocol协议完成的TCP通过诸如连接管理拥塞控制数据流与窗口管理超时和重传等一系列精巧而详细的设计提供了高质量的端到端的通信方式。
这部分内容不是我们这里讲解的重点有感兴趣的同学可以去读《TCP/IP详解卷一协议》 。
我们平时使用浏览器访问网页或者在手机端用天猫App购物时使用的都是字节流套接字。
等等如果是这样世界都用TCP好了哪里有UDP什么事呢
事实上UDP在很多场景也得到了极大的应用比如多人联网游戏、视频会议甚至聊天室。如果你听说过NTP你一定很惊讶NTP也是用UDP实现的。
使用UDP的原因第一是速度第二还是速度。
想象一下一个有上万人的联网游戏如果要给每个玩家同步游戏中其他玩家的位置信息而且丢失一两个也不会造成多大的问题那么UDP是一个比较经济合算的选择。
还有一种叫做广播或多播的技术就是向网络中的多个节点同时发送信息这个时候选择UDP更是非常合适的。
UDP也可以做到更高的可靠性只不过这种可靠性需要应用程序进行设计处理比如对报文进行编号设计Request-Ack机制再加上重传等在一定程度上可以达到更为高可靠的UDP程序。当然这种可靠性和TCP相比还是有一定的距离不过也可以弥补实战中UDP的一些不足。
在后面的章节中我们将会分别介绍TCP和UDP的网络编程技术。
## 总结
这一讲我们主要介绍了客户端-服务器网络编程模型初步介绍了IP地址、端口、子网掩码和域名等基础概念以下知识点你需要重点关注一下
1. 网络编程需要牢牢建立起“客户端”和“服务器”模型,两者编程的方法和框架是明显不同的。
1. TCP连接是客户端-服务器的IP和端口四元组唯一确定的IP是一台机器在网络世界的唯一标识。
1. 有两种截然不同的传输层协议面向连接的“数据流”协议TCP以及无连接的“数据报”协议UDP。
从下一讲开始,我们将开始使用套接字编写我们的第一个客户端-服务器程序。
## 思考题
最后给你布置几个思考题。
我们看到保留地址中第二行172.16.0.0/12描述为16个连续的B段第三行192.168.0.0/16描述为256个连续的C段地址怎么理解这种描述呢
另外,章节里提到了服务端必须侦听在一个众所周知的端口上,这个端口怎么选择,又是如何让客户端知道的呢?
如果你仔细想过这个问题,欢迎在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,大家一起交流一下。

View File

@@ -0,0 +1,219 @@
<audio id="audio" title="03丨套接字和地址像电话和电话号码一样理解它们" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/63/1f/6315819f6bff64a0a17b3a8d8783021f.mp3"></audio>
在网络编程中我们经常会提到socket这个词它的中文翻译为套接字有的时候也叫做套接口。
socket这个英文单词的原意是“插口”“插槽” 在网络编程中它的寓意是可以通过插口接入的方式快速完成网络连接和数据收发。你可以把它想象成现实世界的电源插口或者是早期上网需要的网络插槽所以socket也可以看做是对物理世界的直接映射。
其实计算机程序设计是一门和英文有着紧密联系的学科,很多专有名词使用英文原词比翻译成中文更容易让大家接受。为了方便,在专栏里我们一般会直接使用英文,如果需要翻译就一律用“套接字”这个翻译。
## socket到底是什么
在网络编程中到底应该怎么理解socket呢我在这里先呈上这么一张图你可以先看看。
<img src="https://static001.geekbang.org/resource/image/0b/64/0ba3f3d04b1466262c02d6f24ee76a64.jpg" alt=""><br>
这张图表达的其实是网络编程中,客户端和服务器工作的核心逻辑。
我们先从右侧的服务器端开始看因为在客户端发起连接请求之前服务器端必须初始化好。右侧的图显示的是服务器端初始化的过程首先初始化socket之后服务器端需要执行bind函数将自己的服务能力绑定在一个众所周知的地址和端口上紧接着服务器端执行listen操作将原先的socket转化为服务端的socket服务端最后阻塞在accept上等待客户端请求的到来。
此时服务器端已经准备就绪。客户端需要先初始化socket再执行connect向服务器端的地址和端口发起连接请求这里的地址和端口必须是客户端预先知晓的。这个过程就是著名的**TCP三次握手**Three-way Handshake。下一篇文章我会详细讲到TCP三次握手的原理。
一旦三次握手完成,客户端和服务器端建立连接,就进入了数据传输过程。
具体来说客户端进程向操作系统内核发起write字节流写操作内核协议栈将字节流通过网络设备传输到服务器端服务器端从内核得到信息将字节流从内核读入到进程中并开始业务逻辑的处理完成之后服务器端再将得到的结果以同样的方式写给客户端。可以看到**一旦连接建立数据的传输就不再是单向的而是双向的这也是TCP的一个显著特性**。
当客户端完成和服务器端的交互后比如执行一次Telnet操作或者一次HTTP请求需要和服务器端断开连接时就会执行close函数操作系统内核此时会通过原先的连接链路向服务器端发送一个FIN包服务器收到之后执行被动关闭这时候整个链路处于半关闭状态此后服务器端也会执行close函数整个链路才会真正关闭。半关闭的状态下发起close请求的一方在没有收到对方FIN包之前都认为连接是正常的而在全关闭的状态下双方都感知连接已经关闭。
请你牢牢记住文章开头的那幅图,它是贯穿整个专栏的核心图之一。
讲这幅图的真正用意在于引入socket的概念请注意以上所有的操作都是通过socket来完成的。无论是客户端的connect还是服务端的accept或者read/write操作等**socket是我们用来建立连接传输数据的唯一途径**。
### 更好地理解socket一个更直观的解释
你可以把整个TCP的网络交互和数据传输想象成打电话顺着这个思路想象socket就好像是我们手里的电话机connect就好比拿着电话机拨号而服务器端的bind就好比是去电信公司开户将电话号码和我们家里的电话机绑定这样别人就可以用这个号码找到你listen就好似人们在家里听到了响铃accept就好比是被叫的一方拿起电话开始应答。至此三次握手就完成了连接建立完毕。
接下来拨打电话的人开始说话“你好。”这时就进入了write接收电话的人听到的过程可以想象成read听到并读出数据并且开始应答双方就进入了read/write的数据传输过程。
最后拨打电话的人完成了此次交流挂上电话对应的操作可以理解为close接听电话的人知道对方已挂机也挂上电话也是一次close。
在整个电话交流过程中电话是我们可以和外面通信的设备对应到网络编程的世界里socket也是我们可以和外界进行网络通信的途径。
### socket的发展历史
通过上面的讲解和这个打电话的类比你现在清楚socket到底是什么了吧那socket最开始是怎么被提出来的呢接下来就很有必要一起来简单追溯一下它的历史了。
socket是加州大学伯克利分校的研究人员在20世纪80年代早期提出的所以也被叫做伯克利套接字。伯克利的研究者们设想用socket的概念屏蔽掉底层协议栈的差别。第一版实现socket的就是TCP/IP协议最早是在BSD 4.2 Unix内核上实现了socket。很快大家就发现这么一个概念带来了网络编程的便利于是有更多人也接触到了socket的概念。Linux作为Unix系统的一个开源实现很早就从头开发实现了TCP/IP协议伴随着socket的成功Windows也引入了socket的概念。于是在今天的世界里socket成为网络互联互通的标准。
## 套接字地址格式
在使用套接字时,首先要解决通信双方寻址的问题。我们需要套接字的地址建立连接,就像打电话时首先需要查找电话簿,找到你想要联系的那个人,你才可以建立连接,开始交流。接下来,我们重点讨论套接字的地址格式。
### 通用套接字地址格式
下面先看一下套接字的**通用**地址结构:
```
/* POSIX.1g 规范规定了地址族为2字节的值. */
typedef unsigned short int sa_family_t;
/* 描述通用套接字地址 */
struct sockaddr{
sa_family_t sa_family; /* 地址族. 16-bit*/
char sa_data[14]; /* 具体的地址值 112-bit */
};
```
在这个结构体里第一个字段是地址族它表示使用什么样的方式对地址进行解释和保存好比电话簿里的手机格式或者是固话格式这两种格式的长度和含义都是不同的。地址族在glibc里的定义非常多常用的有以下几种
- AF_LOCAL表示的是本地地址对应的是Unix套接字这种情况一般用于本地socket通信很多情况下也可以写成AF_UNIX、AF_FILE
- AF_INET因特网使用的IPv4地址
- AF_INET6因特网使用的IPv6地址。
这里的AF_表示的含义是Address Family但是很多情况下我们也会看到以PF_表示的宏比如PF_INET、PF_INET6等实际上PF_的意思是Protocol Family也就是协议族的意思。我们用AF_xxx这样的值来初始化socket地址用PF_xxx这样的值来初始化socket。我们在&lt;sys/socket.h&gt;头文件中可以清晰地看到,这两个值本身就是一一对应的。
```
/* 各种地址族的宏定义 */
#define AF_UNSPEC PF_UNSPEC
#define AF_LOCAL PF_LOCAL
#define AF_UNIX PF_UNIX
#define AF_FILE PF_FILE
#define AF_INET PF_INET
#define AF_AX25 PF_AX25
#define AF_IPX PF_IPX
#define AF_APPLETALK PF_APPLETALK
#define AF_NETROM PF_NETROM
#define AF_BRIDGE PF_BRIDGE
#define AF_ATMPVC PF_ATMPVC
#define AF_X25 PF_X25
#define AF_INET6 PF_INET6
```
sockaddr是一个通用的地址结构通用的意思是适用于多种地址族。为什么定义这么一个通用地址结构呢这个放在后面讲。
### IPv4套接字格式地址
接下来看一下常用的IPv4地址族的结构
```
/* IPV4套接字地址32bit值. */
typedef uint32_t in_addr_t;
struct in_addr
{
in_addr_t s_addr;
};
/* 描述IPV4的套接字地址格式 */
struct sockaddr_in
{
sa_family_t sin_family; /* 16-bit */
in_port_t sin_port; /* 端口号 16-bit*/
struct in_addr sin_addr; /* Internet address. 32-bit */
/* 这里仅仅用作占位符,不做实际用处 */
unsigned char sin_zero[8];
};
```
我们对这个结构体稍作解读首先可以发现和sockaddr一样都有一个16-bit的sin_family字段对于IPv4来说这个值就是AF_INET。
接下来是端口号我们可以看到端口号最多是16-bit也就是说最大支持2的16次方这个数字是65536所以我们应该知道支持寻址的端口号最多就是65535。关于端口我在前面的章节也提到过这里重点阐述一下保留端口。所谓保留端口就是大家约定俗成的已经被对应服务广为使用的端口比如ftp的21端口ssh的22端口http的80端口等。一般而言大于5000的端口可以作为我们自己应用程序的端口使用。
下面是glibc定义的保留端口。
```
/* Standard well-known ports. */
enum
{
IPPORT_ECHO = 7, /* Echo service. */
IPPORT_DISCARD = 9, /* Discard transmissions service. */
IPPORT_SYSTAT = 11, /* System status service. */
IPPORT_DAYTIME = 13, /* Time of day service. */
IPPORT_NETSTAT = 15, /* Network status service. */
IPPORT_FTP = 21, /* File Transfer Protocol. */
IPPORT_TELNET = 23, /* Telnet protocol. */
IPPORT_SMTP = 25, /* Simple Mail Transfer Protocol. */
IPPORT_TIMESERVER = 37, /* Timeserver service. */
IPPORT_NAMESERVER = 42, /* Domain Name Service. */
IPPORT_WHOIS = 43, /* Internet Whois service. */
IPPORT_MTP = 57,
IPPORT_TFTP = 69, /* Trivial File Transfer Protocol. */
IPPORT_RJE = 77,
IPPORT_FINGER = 79, /* Finger service. */
IPPORT_TTYLINK = 87,
IPPORT_SUPDUP = 95, /* SUPDUP protocol. */
IPPORT_EXECSERVER = 512, /* execd service. */
IPPORT_LOGINSERVER = 513, /* rlogind service. */
IPPORT_CMDSERVER = 514,
IPPORT_EFSSERVER = 520,
/* UDP ports. */
IPPORT_BIFFUDP = 512,
IPPORT_WHOSERVER = 513,
IPPORT_ROUTESERVER = 520,
/* Ports less than this value are reserved for privileged processes. */
IPPORT_RESERVED = 1024,
/* Ports greater this value are reserved for (non-privileged) servers. */
IPPORT_USERRESERVED = 5000
```
实际的IPv4地址是一个32-bit的字段可以想象最多支持的地址数就是2的32次方大约是42亿应该说这个数字在设计之初还是非常巨大的无奈互联网蓬勃发展全球接入的设备越来越多这个数字渐渐显得不太够用了于是大家所熟知的IPv6就隆重登场了。
### IPv6套接字地址格式
我们再看看IPv6的地址结构
```
struct sockaddr_in6
{
sa_family_t sin6_family; /* 16-bit */
in_port_t sin6_port; /* 传输端口号 # 16-bit */
uint32_t sin6_flowinfo; /* IPv6流控信息 32-bit*/
struct in6_addr sin6_addr; /* IPv6地址128-bit */
uint32_t sin6_scope_id; /* IPv6域ID 32-bit */
};
```
整个结构体长度是28个字节其中流控信息和域ID先不用管这两个字段一个在glibc的官网上根本没出现另一个是当前未使用的字段。这里的地址族显然应该是AF_INET6端口同IPv4地址一样关键的地址从32位升级到128位这个数字就大到恐怖了完全解决了寻址数字不够的问题。
请注意以上无论IPv4还是IPv6的地址格式都是因特网套接字的格式还有一种本地套接字格式用来作为本地进程间的通信 也就是前面提到的AF_LOCAL。
```
struct sockaddr_un {
unsigned short sun_family; /* 固定为 AF_LOCAL */
char sun_path[108]; /* 路径名 */
};
```
### 几种套接字地址格式比较
这几种地址的比较见下图IPv4和IPv6套接字地址结构的长度是固定的而本地地址结构的长度是可变的。
<img src="https://static001.geekbang.org/resource/image/ed/58/ed49b0f1b658e82cb07a6e1e81f36b58.png" alt="">
## 总结
这一讲我们重点讲述了什么是套接字以及对应的套接字地址格式。套接字作为网络编程的基础概念异常重要。套接字的设计为我们打开了网络编程的大门实际上正是因为BSD套接字如此成功各大Unix厂商包括开源的Linux以及Windows平台才会很快照搬了过来。在下一讲中我们将开始创建并使用套接字建立连接进一步开始我们的网络编程之旅。
## 思考题
最后给你留两道思考题吧你可以想一想IPv4、IPv6、本地套接字格式以及通用地址套接字它们有什么共性呢如果你是BSD套接字的设计者你为什么要这样设计呢
第二道题是为什么本地套接字格式不需要端口号而IPv4和IPv6套接字格式却需要端口号呢
我在评论区期待你的思考与见解,如果你觉得这篇文章对你有所帮助,欢迎点击“请朋友读”,把这篇文章分享给你朋友或同事。

View File

@@ -0,0 +1,229 @@
<audio id="audio" title="04 | TCP三次握手怎么使用套接字格式建立连接" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f2/3e/f2354a6313ce726e7f3b86cf1428ca3e.mp3"></audio>
你好我是盛延敏这里是网络编程实战第4讲欢迎回来。
在上一讲里我们介绍了IPv4、IPv6以及本地套接字格式这一讲我们来讲一讲怎么使用这些套接字格式完成连接的建立当然经典的TCP三次握手理论也会贯穿其中。我希望经过这一讲的讲解你会牢牢记住TCP三次握手和客户端、服务器模型。
让我们先从服务器端开始。
## 服务端准备连接的过程
### 创建套接字
要创建一个可用的套接字,需要使用下面的函数:
```
int socket(int domain, int type, int protocol)
```
domain就是指PF_INET、PF_INET6以及PF_LOCAL等表示什么样的套接字。
type可用的值是
- **SOCK_STREAM: 表示的是字节流对应TCP**
- **SOCK_DGRAM 表示的是数据报对应UDP**
- **SOCK_RAW: 表示的是原始套接字。**
参数protocol原本是用来指定通信协议的但现在基本废弃。因为协议已经通过前面两个参数指定完成。protocol目前一般写成0即可。
### bind: 设定电话号码
创建出来的套接字如果需要被别人使用就需要调用bind函数把套接字和套接字地址绑定就像去电信局登记我们的电话号码一样。
调用bind函数的方式如下
```
bind(int fd, sockaddr * addr, socklen_t len)
```
我们需要注意到bind函数后面的第二个参数是通用地址格式`sockaddr * addr`。这里有一个地方值得注意那就是虽然接收的是通用地址格式实际上传入的参数可能是IPv4、IPv6或者本地套接字格式。bind函数会根据len字段判断传入的参数addr该怎么解析len字段表示的就是传入的地址长度它是一个可变值。
这里其实可以把bind函数理解成这样
```
bind(int fd, void * addr, socklen_t len)
```
不过BSD设计套接字的时候大约是1982年那个时候的C语言还没有`void *`的支持为了解决这个问题BSD的设计者们创造性地设计了通用地址格式来作为支持bind和accept等这些函数的参数。
对于使用者来说每次需要将IPv4、IPv6或者本地套接字格式转化为通用套接字格式就像下面的IPv4套接字地址格式的例子一样
```
struct sockaddr_in name;
bind (sock, (struct sockaddr *) &amp;name, sizeof (name)
```
对于实现者来说可根据该地址结构的前两个字节判断出是哪种地址。为了处理长度可变的结构需要读取函数里的第三个参数也就是len字段这样就可以对地址进行解析和判断了。
设置bind的时候对地址和端口可以有多种处理方式。
我们可以把地址设置成本机的IP地址这相当告诉操作系统内核仅仅对目标IP是本机IP地址的IP包进行处理。但是这样写的程序在部署时有一个问题我们编写应用程序时并不清楚自己的应用程序将会被部署到哪台机器上。这个时候可以利用**通配地址**的能力帮助我们解决这个问题。通配地址相当于告诉操作系统内核“Hi我可不挑活只要目标地址是咱们的都可以。”比如一台机器有两块网卡IP地址分别是202.61.22.55和192.168.1.11那么向这两个IP请求的请求包都会被我们编写的应用程序处理。
那么该如何设置通配地址呢?
对于IPv4的地址来说使用INADDR_ANY来完成通配地址的设置对于IPv6的地址来说使用IN6ADDR_ANY来完成通配地址的设置。
```
struct sockaddr_in name;
name.sin_addr.s_addr = htonl (INADDR_ANY); /* IPV4通配地址 */
```
除了地址还有端口。如果把端口设置成0就相当于把端口的选择权交给操作系统内核来处理操作系统内核会根据一定的算法选择一个空闲的端口完成套接字的绑定。这在服务器端不常使用。
一般来说服务器端的程序一定要绑定到一个众所周知的端口上。服务器端的IP地址和端口数据相当于打电话拨号时需要知道的对方号码如果没有电话号码就没有办法和对方建立连接。
我们来看一个初始化IPv4 TCP 套接字的例子:
```
#include &lt;stdio.h&gt;
#include &lt;stdlib.h&gt;
#include &lt;sys/socket.h&gt;
#include &lt;netinet/in.h&gt;
int make_socket (uint16_t port)
{
int sock;
struct sockaddr_in name;
/* 创建字节流类型的IPV4 socket. */
sock = socket (PF_INET, SOCK_STREAM, 0);
if (sock &lt; 0)
{
perror (&quot;socket&quot;);
exit (EXIT_FAILURE);
}
/* 绑定到port和ip. */
name.sin_family = AF_INET; /* IPV4 */
name.sin_port = htons (port); /* 指定端口 */
name.sin_addr.s_addr = htonl (INADDR_ANY); /* 通配地址 */
/* 把IPV4地址转换成通用地址格式同时传递长度 */
if (bind (sock, (struct sockaddr *) &amp;name, sizeof (name)) &lt; 0)
{
perror (&quot;bind&quot;);
exit (EXIT_FAILURE);
}
return sock
}
```
### listen接上电话线一切准备就绪
bind函数只是让我们的套接字和地址关联如同登记了电话号码。如果要让别人打通电话还需要我们把电话设备接入电话线让服务器真正处于可接听的状态这个过程需要依赖listen函数。
初始化创建的套接字,可以认为是一个"主动"套接字其目的是之后主动发起请求通过调用connect函数后面会讲到。通过listen函数可以将原来的"主动"套接字转换为"被动"套接字,告诉操作系统内核:“我这个套接字是用来等待用户请求的。”当然,操作系统内核会为此做好接收用户请求的一切准备,比如完成连接队列。
listen函数的原型是这样的
```
int listen (int socketfd, int backlog)
```
我来稍微解释一下。第一个参数socketdf为套接字描述符第二个参数backlog在Linux中表示已完成(ESTABLISHED)且未accept的队列大小这个参数的大小决定了可以接收的并发数目。这个参数越大并发数目理论上也会越大。但是参数过大也会占用过多的系统资源一些系统比如Linux并不允许对这个参数进行改变。对于backlog整个参数的设置有一些最佳实践这里就不展开后面结合具体的实例进行解读。
### accept: 电话铃响起了……
当客户端的连接请求到达时,服务器端应答成功,连接建立,这个时候操作系统内核需要把这个事件通知到应用程序,并让应用程序感知到这个连接。这个过程,就好比电信运营商完成了一次电话连接的建立, 应答方的电话铃声响起,通知有人拨打了号码,这个时候就需要拿起电话筒开始应答。
连接建立之后你可以把accept这个函数看成是操作系统内核和应用程序之间的桥梁。它的原型是
```
int accept(int listensockfd, struct sockaddr *cliaddr, socklen_t *addrlen)
```
函数的第一个参数listensockfd是套接字可以叫它为listen套接字因为这就是前面通过bindlisten一系列操作而得到的套接字。函数的返回值有两个部分第一个部分cliadd是通过指针方式获取的客户端的地址addrlen告诉我们地址的大小这可以理解成当我们拿起电话机时看到了来电显示知道了对方的号码另一个部分是函数的返回值这个返回值是一个全新的描述字代表了与客户端的连接。
这里一定要注意有两个套接字描述字第一个是监听套接字描述字listensockfd它是作为输入参数存在的第二个是返回的已连接套接字描述字。
你可能会问,为什么要把两个套接字分开呢?用一个不是挺好的么?
这里和打电话的情形非常不一样的地方就在于,打电话一旦有一个连接建立,别人是不能再打进来的,只会得到语音播报:“您拨的电话正在通话中。”而网络程序的一个重要特征就是并发处理,不可能一个应用程序运行之后只能服务一个客户,如果是这样, 双11抢购得需要多少服务器才能满足全国 “剁手党 ” 的需求?
所以监听套接字一直都存在它是要为成千上万的客户来服务的直到这个监听套接字关闭而一旦一个客户和服务器连接成功完成了TCP三次握手操作系统内核就为这个客户生成一个已连接套接字让应用服务器使用这个**已连接套接字**和客户进行通信处理。如果应用服务器完成了对这个客户的服务,比如一次网购下单,一次付款成功,那么关闭的就是**已连接套接字**这样就完成了TCP连接的释放。请注意这个时候释放的只是这一个客户连接其它被服务的客户连接可能还存在。最重要的是监听套接字一直都处于“监听”状态等待新的客户请求到达并服务。
## 客户端发起连接的过程
前面讲述的bind、listen以及accept的过程是典型的服务器端的过程。下面我来讲下客户端发起连接请求的过程。
第一步还是和服务端一样,要建立一个套接字,方法和前面是一样的。
不一样的是客户端需要调用connect向服务端发起请求。
### connect: 拨打电话
客户端和服务器端的连接建立是通过connect函数完成的。这是connect的构建函数
```
int connect(int sockfd, const struct sockaddr *servaddr, socklen_t addrlen)
```
函数的第一个参数sockfd是连接套接字通过前面讲述的socket函数创建。第二个、第三个参数servaddr和addrlen分别代表指向套接字地址结构的指针和该结构的大小。套接字地址结构必须含有服务器的IP地址和端口号。
客户在调用函数connect前不必非得调用bind函数因为如果需要的话内核会确定源IP地址并按照一定的算法选择一个临时端口作为源端口。
如果是TCP套接字那么调用connect函数将激发TCP的三次握手过程而且仅在连接建立成功或出错时才返回。其中出错返回可能有以下几种情况
1. 三次握手无法建立客户端发出的SYN包没有任何响应于是返回TIMEOUT错误。这种情况比较常见的原因是对应的服务端IP写错。
1. 客户端收到了RST复位回答这时候客户端会立即返回CONNECTION REFUSED错误。这种情况比较常见于客户端发送连接请求时的请求端口写错因为RST是TCP在发生错误时发送的一种TCP分节。产生RST的三个条件是目的地为某端口的SYN到达然而该端口上没有正在监听的服务器如前所述TCP想取消一个已有连接TCP接收到一个根本不存在的连接上的分节。
1. 客户发出的SYN包在网络上引起了"destination unreachable",即目的不可达的错误。这种情况比较常见的原因是客户端和服务器端路由不通。
根据不同的返回值,我们可以做进一步的排查。
## 著名的TCP三次握手: 这一次不用背记
<img src="https://static001.geekbang.org/resource/image/65/29/65cef2c44480910871a0b66cac1d5529.png" alt=""><br>
你在各个场合都会了解到著名的TCP三次握手可能还会被要求背下三次握手整个过程但背后的原理和过程可能未必真正理解。我们刚刚学习了服务端和客户端连接的主要函数下面结合这些函数讲解一下TCP三次握手的过程。这样我相信你不用背也能根据理解轻松掌握这部分的知识。
这里我们使用的网络编程模型都是阻塞式的。所谓阻塞式,就是调用发起后不会直接返回,由操作系统内核处理之后才会返回。 相对的,还有一种叫做非阻塞式的,我们在后面的章节里会讲到。
## TCP三次握手的解读
我们先看一下最初的过程服务器端通过socketbind和listen完成了被动套接字的准备工作被动的意思就是等着别人来连接然后调用accept就会阻塞在这里等待客户端的连接来临客户端通过调用socket和connect函数之后也会阻塞。接下来的事情是由操作系统内核完成的更具体一点的说是操作系统内核网络协议栈在工作。
下面是具体的过程:
1. 客户端的协议栈向服务器端发送了SYN包并告诉服务器端当前发送序列号j客户端进入SYNC_SENT状态
1. 服务器端的协议栈收到这个包之后和客户端进行ACK应答应答的值为j+1表示对SYN包j的确认同时服务器也发送一个SYN包告诉客户端当前我的发送序列号为k服务器端进入SYNC_RCVD状态
1. 客户端协议栈收到ACK之后使得应用程序从connect调用返回表示客户端到服务器端的单向连接建立成功客户端的状态为ESTABLISHED同时客户端协议栈也会对服务器端的SYN包进行应答应答数据为k+1
1. 应答包到达服务器端后服务器端协议栈使得accept阻塞调用返回这个时候服务器端到客户端的单向连接也建立成功服务器端也进入ESTABLISHED状态。
形象一点的比喻是这样的有A和B想进行通话
- A先对B说“喂你在么我在的我的口令是j。”
- B收到之后大声回答“我收到你的口令j并准备好了你准备好了吗我的口令是k。”
- A收到之后也大声回答“我收到你的口令k并准备好了我们开始吧。”
可以看到这样的应答过程总共进行了三次这就是TCP连接建立之所以被叫为“三次握手”的原因了。
## 总结
这一讲我们分别从服务端和客户端的角度讲述了如何创建套接字并利用套接字完成TCP连接的建立。
- 服务器端通过创建socketbindlisten完成初始化通过accept完成连接的建立。
- 客户端通过创建socketconnect发起连接建立请求。
在下一讲里,我们将真正地开始客户端-服务端数据交互的过程。
## 思考题
最后给你布置两道思考题。
第一道是关于阻塞调用的,既然有阻塞调用,就应该有非阻塞调用,那么如何使用非阻塞调用套接字呢?使用的场景又是哪里呢?
第二道是关于客户端的客户端发起connect调用之前可以调用bind函数么
欢迎你在评论区与我分享你的答案如果这篇文章帮助你理解TCP三次握手也欢迎你点击“请朋友读”把这篇文章分享给你的朋友或者同事。

View File

@@ -0,0 +1,269 @@
<audio id="audio" title="05 | 使用套接字进行读写:开始交流吧" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4b/80/4b7f310a64b309ef7f314fc98eaabb80.mp3"></audio>
你好我是盛延敏这里是网络编程实战第5讲欢迎回来。
在前面的章节中我们讲述了套接字相关的知识包括套接字的格式套接字的创建以及TCP连接的建立等。在这一讲里我来讲一下如何使用创建的套接字收发数据。
连接建立的根本目的是为了数据的收发。拿我们常用的网购场景举例子,我们在浏览商品或者购买货品的时候,并不会察觉到网络连接的存在,但是我们可以真切感觉到数据在客户端和服务器端有效的传送, 比如浏览商品时商品信息的不断刷新,购买货品时显示购买成功的消息等。
首先我们先来看一下发送数据。
## 发送数据
发送数据时常用的有三个函数分别是write、send和sendmsg。
```
ssize_t write (int socketfd, const void *buffer, size_t size)
ssize_t send (int socketfd, const void *buffer, size_t size, int flags)
ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags)
```
每个函数都是单独使用的,使用的场景略有不同:
第一个函数是常见的文件写函数如果把socketfd换成文件描述符就是普通的文件写入。
如果想指定选项发送带外数据就需要使用第二个带flag的函数。所谓带外数据是一种基于TCP协议的紧急数据用于客户端-服务器在特定场景下的紧急处理。
如果想指定多重缓冲区传输数据就需要使用第三个函数以结构体msghdr的方式发送数据。
你看到这里可能会问既然套接字描述符是一种特殊的描述符那么在套接字描述符上调用write函数应该和在普通文件描述符上调用write函数的行为是一致的都是通过描述符句柄写入指定的数据。
乍一看,两者的表现形式是一样,内在的区别还是很不一样的。
对于普通文件描述符而言一个文件描述符代表了打开的一个文件句柄通过调用write函数操作系统内核帮我们不断地往文件系统中写入字节流。注意写入的字节流大小通常和输入参数size的值是相同的否则表示出错。
对于套接字描述符而言它代表了一个双向连接在套接字描述符上调用write写入的字节数**有可能**比请求的数量少,这在普通文件描述符情况下是不正常的。
产生这个现象的原因在于操作系统内核为读取和发送数据做了很多我们表面上看不到的工作。接下来我拿write函数举例重点阐述发送缓冲区的概念。
### 发送缓冲区
你一定要建立一个概念当TCP三次握手成功TCP连接成功建立后操作系统内核会为每一个连接创建配套的基础设施比如**发送缓冲区**。
发送缓冲区的大小可以通过套接字选项来改变当我们的应用程序调用write函数时实际所做的事情是把数据**从应用程序中拷贝到操作系统内核的发送缓冲区中**,并不一定是把数据通过套接字写出去。
这里有几种情况:
第一种情况很简单操作系统内核的发送缓冲区足够大可以直接容纳这份数据那么皆大欢喜我们的程序从write调用中退出返回写入的字节数就是应用程序的数据大小。
第二种情况是,操作系统内核的发送缓冲区是够大了,不过还有数据没有发送完,或者数据发送完了,但是操作系统内核的发送缓冲区不足以容纳应用程序数据,在这种情况下,你预料的结果是什么呢?报错?还是直接返回?
操作系统内核并不会返回也不会报错而是应用程序被阻塞也就是说应用程序在write函数调用处停留不直接返回。术语“挂起”也表达了相同的意思不过“挂起”是从操作系统内核角度来说的。
那么什么时候才会返回呢?
实际上每个操作系统内核的处理是不同的。大部分UNIX系统的做法是一直等到可以把应用程序数据完全放到操作系统内核的发送缓冲区中再从系统调用中返回。怎么理解呢
别忘了我们的操作系统内核是很聪明的当TCP连接建立之后它就开始运作起来。你可以把发送缓冲区想象成一条包裹流水线有个聪明且忙碌的工人不断地从流水线上取出包裹数据这个工人会按照TCP/IP的语义将取出的包裹数据封装成TCP的MSS包以及IP的MTU包最后走数据链路层将数据发送出去。这样我们的发送缓冲区就又空了一部分于是又可以继续从应用程序搬一部分数据到发送缓冲区里这样一直进行下去到某一个时刻应用程序的数据可以完全放置到发送缓冲区里。在这个时候write阻塞调用返回。注意返回的时刻应用程序数据并没有全部被发送出去发送缓冲区里还有部分数据这部分数据会在稍后由操作系统内核通过网络发送出去。
<img src="https://static001.geekbang.org/resource/image/fd/dc/fdcdc766c6a6ebb7fbf15bb2d1e58bdc.png" alt="">
## 读取数据
我们可以注意到,套接字描述本身和本地文件描述符并无区别,**在UNIX的世界里万物都是文件**这就意味着可以将套接字描述符传递给那些原先为处理本地文件而设计的函数。这些函数包括read和write交换数据的函数。
### read函数
让我们先从最简单的read函数开始看起这个函数的原型如下
```
ssize_t read (int socketfd, void *buffer, size_t size)
```
read函数要求操作系统内核从套接字描述字socketfd**读取最多多少个字节size并将结果存储到buffer中。返回值告诉我们实际读取的字节数目也有一些特殊情况如果返回值为0表示EOFend-of-file这在网络中表示对端发送了FIN包要处理断连的情况**;如果返回值为-1表示出错。当然如果是非阻塞I/O情况会略有不同在后面的提高篇中我们会重点讲述非阻塞I/O的特点。
注意这里是最多读取size个字节。如果我们想让应用程序每次都读到size个字节就需要编写下面的函数不断地循环读取。
```
/* 从socketfd描述字中读取&quot;size&quot;个字节. */
size_t readn(int fd, void *buffer, size_t size) {
char *buffer_pointer = buffer;
int length = size;
while (length &gt; 0) {
int result = read(fd, buffer_pointer, length);
if (result &lt; 0) {
if (errno == EINTR)
continue; /* 考虑非阻塞的情况这里需要再次调用read */
else
return (-1);
} else if (result == 0)
break; /* EOF(End of File)表示套接字关闭 */
length -= result;
buffer_pointer += result;
}
return (size - length); /* 返回的是实际读取的字节数*/
}
```
对这个程序稍微解释下:
- 6-19行的循环条件表示的是在没读满size个字节之前一直都要循环下去。
- 10-11行表示的是非阻塞I/O的情况下没有数据可以读需要继续调用read。
- 14-15行表示读到对方发出的FIN包表现形式是EOF此时需要关闭套接字。
- 17-18行需要读取的字符数减少缓存指针往下移动。
- 20行是在读取EOF跳出循环后返回实际读取的字符数。
## 缓冲区实验
我们用一个客户端-服务器的例子来解释一下读取缓冲区和发送缓冲区的概念。在这个例子中客户端不断地发送数据,服务器端每读取一段数据之后进行休眠,以模拟实际业务处理所需要的时间。
### 服务器端读取数据程序
下面是服务器端读取数据的程序:
```
#include &quot;lib/common.h&quot;
void read_data(int sockfd) {
ssize_t n;
char buf[1024];
int time = 0;
for (;;) {
fprintf(stdout, &quot;block in read\n&quot;);
if ((n = readn(sockfd, buf, 1024)) == 0)
return;
time++;
fprintf(stdout, &quot;1K read for %d \n&quot;, time);
usleep(1000);
}
}
int main(int argc, char **argv) {
int listenfd, connfd;
socklen_t clilen;
struct sockaddr_in cliaddr, servaddr;
listenfd = socket(AF_INET, SOCK_STREAM, 0);
bzero(&amp;servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(12345);
/* bind到本地地址端口为12345 */
bind(listenfd, (struct sockaddr *) &amp;servaddr, sizeof(servaddr));
/* listen的backlog为1024 */
listen(listenfd, 1024);
/* 循环处理用户请求 */
for (;;) {
clilen = sizeof(cliaddr);
connfd = accept(listenfd, (struct sockaddr *) &amp;cliaddr, &amp;clilen);
read_data(connfd); /* 读取数据 */
close(connfd); /* 关闭连接套接字,注意不是监听套接字*/
}
}
```
对服务器端程序解释如下:
- 21-35行先后创建了socket套接字bind到对应地址和端口并开始调用listen接口监听
- 38-42行循环等待连接通过accept获取实际的连接并开始读取数据
- 8-15行实际每次读取1K数据之后休眠1秒用来模拟服务器端处理时延。
### 客户端发送数据程序
下面是客户端发送数据的程序:
```
#include &quot;lib/common.h&quot;
#define MESSAGE_SIZE 102400
void send_data(int sockfd) {
char *query;
query = malloc(MESSAGE_SIZE + 1);
for (int i = 0; i &lt; MESSAGE_SIZE; i++) {
query[i] = 'a';
}
query[MESSAGE_SIZE] = '\0';
const char *cp;
cp = query;
size_t remaining = strlen(query);
while (remaining) {
int n_written = send(sockfd, cp, remaining, 0);
fprintf(stdout, &quot;send into buffer %ld \n&quot;, n_written);
if (n_written &lt;= 0) {
error(1, errno, &quot;send failed&quot;);
return;
}
remaining -= n_written;
cp += n_written;
}
return;
}
int main(int argc, char **argv) {
int sockfd;
struct sockaddr_in servaddr;
if (argc != 2)
error(1, 0, &quot;usage: tcpclient &lt;IPaddress&gt;&quot;);
sockfd = socket(AF_INET, SOCK_STREAM, 0);
bzero(&amp;servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(12345);
inet_pton(AF_INET, argv[1], &amp;servaddr.sin_addr);
int connect_rt = connect(sockfd, (struct sockaddr *) &amp;servaddr, sizeof(servaddr));
if (connect_rt &lt; 0) {
error(1, errno, &quot;connect failed &quot;);
}
send_data(sockfd);
exit(0);
}
```
对客户端程序解释如下:
- 31-37行先后创建了socket套接字调用connect向对应服务器端发起连接请求
- 43行在连接建立成功后调用send_data发送数据
- 6-11行初始化了一个长度为MESSAGE_SIZE的字符串流
- 16-25行调用send函数将MESSAGE_SIZE长度的字符串流发送出去
### 实验一: 观察客户端数据发送行为
客户端程序发送了一个很大的字节流,程序运行起来之后,我们会看到服务端不断地在屏幕上打印出读取字节流的过程:
<img src="https://static001.geekbang.org/resource/image/34/1d/3455bb84f5ee020bc14bc1e15ead4d1d.jpg" alt=""><br>
而客户端直到最后所有的字节流发送完毕才打印出下面的一句话说明在此之前send函数一直都是阻塞的也就是说**阻塞式套接字最终发送返回的实际写入字节数和请求字节数是相等的。**
而关于非阻塞套接字的操作,我会在后面的文章中讲解。
### 实验二: 服务端处理变慢
如果我们把服务端的休眠时间稍微调大把客户端发送的字节数从10240000调整为1024000再次运行刚才的例子我们会发现客户端很快打印出一句话
<img src="https://static001.geekbang.org/resource/image/b5/e6/b56f01f842b2344e1480ff519d1627e6.jpg" alt=""><br>
但与此同时,服务端读取程序还在屏幕上不断打印读取数据的进度,显示出服务端读取程序还在辛苦地从缓冲区中读取数据。
通过这个例子我想再次强调一下:
**发送成功仅仅表示的是数据被拷贝到了发送缓冲区中,并不意味着连接对端已经收到所有的数据。至于什么时候发送到对端的接收缓冲区,或者更进一步说,什么时候被对方应用程序缓冲所接收,对我们而言完全都是透明的。**
## 总结
这一讲重点讲述了通过send和read来收发数据包你需要牢记以下两点
- 对于send来说返回成功仅仅表示数据写到发送缓冲区成功并不表示对端已经成功收到。
- 对于read来说需要循环读取数据并且需要考虑EOF等异常条件。
## 思考题
最后你不妨思考一下,既然缓冲区如此重要,我们可不可以把缓冲区搞得大大的,这样不就可以提高应用程序的吞吐量了么?你可以想一想这个方法可行吗?另外你可以自己总结一下,一段数据流从应用程序发送端,一直到应用程序接收端,总共经过了多少次拷贝?
欢迎你在评论区与我分享你的答案,如果你理解了套接字读写的过程,也欢迎把这篇文章分享给你的朋友或者同事。

View File

@@ -0,0 +1,366 @@
<audio id="audio" title="06 | 嗨别忘了UDP这个小兄弟" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/12/02/1264e068377b6ef9955d5d0774fb0902.mp3"></audio>
你好我是盛延敏这里是网络编程实战第6讲欢迎回来。
前面几讲我们讲述了TCP方面的编程知识这一讲我们来讲讲UDP方面的编程知识。
如果说TCP是网络协议的“大哥”那么UDP可以说是“小兄弟”。这个小兄弟和大哥比有什么差异呢
首先UDP是一种“数据报”协议而TCP是一种面向连接的“数据流”协议。
TCP可以用日常生活中打电话的场景打比方前面也多次用到了这样的例子。在这个例子中拨打号码、接通电话、开始交流分别对应了TCP的三次握手和报文传送。一旦双方的连接建立那么双方对话时一定知道彼此是谁。这个时候我们就说这种对话是有上下文的。
同样的我们也可以给UDP找一个类似的例子这个例子就是邮寄明信片。在这个例子中发信方在明信片中填上了接收方的地址和邮编投递到邮局的邮筒之后就可以不管了。发信方也可以给这个接收方再邮寄第二张、第三张甚至是第四张明信片但是这几张明信片之间是没有任何关系的他们的到达顺序也是不保证的有可能最后寄出的第四张明信片最先到达接收者的手中因为没有序号接收者也不知道这是第四张寄出的明信片而且即使接收方没有收到明信片也没有办法重新邮寄一遍该明信片。
这两个简单的例子道出了UDP和TCP之间最大的区别。
TCP是一个面向连接的协议TCP在IP报文的基础上增加了诸如重传、确认、有序传输、拥塞控制等能力通信的双方是在一个确定的上下文中工作的。
而UDP则不同UDP没有这样一个确定的上下文它是一个不可靠的通信协议没有重传和确认没有有序控制也没有拥塞控制。我们可以简单地理解为在IP报文的基础上UDP增加的能力有限。
UDP不保证报文的有效传递不保证报文的有序也就是说使用UDP的时候我们需要做好丢包、重传、报文组装等工作。
既然如此为什么我们还要使用UDP协议呢
答案很简单因为UDP比较简单适合的场景还是比较多的我们常见的DNS服务SNMP服务都是基于UDP协议的这些场景对时延、丢包都不是特别敏感。另外多人通信的场景如聊天室、多人游戏等也都会使用到UDP协议。
## UDP编程
UDP和TCP编程非常不同下面这张图是UDP程序设计时的主要过程。
<img src="https://static001.geekbang.org/resource/image/84/30/8416f0055bedce10a3c7d0416cc1f430.png" alt=""><br>
我们看到服务器端创建UDP 套接字之后绑定到本地端口调用recvfrom函数等待客户端的报文发送客户端创建套接字之后调用sendto函数往目标地址和端口发送UDP报文然后客户端和服务器端进入互相应答过程。
recvfrom和sendto是UDP用来接收和发送报文的两个主要函数
```
#include &lt;sys/socket.h&gt;
ssize_t recvfrom(int sockfd, void *buff, size_t nbytes, int flags,
          struct sockaddr *from, socklen_t *addrlen);
ssize_t sendto(int sockfd, const void *buff, size_t nbytes, int flags,
const struct sockaddr *to, socklen_t addrlen);
```
我们先来看一下recvfrom函数。
sockfd、buff和nbytes是前三个参数。sockfd是本地创建的套接字描述符buff指向本地的缓存nbytes表示最大接收数据字节。
第四个参数flags是和I/O相关的参数这里我们还用不到设置为0。
后面两个参数from和addrlen实际上是返回对端发送方的地址和端口等信息这和TCP非常不一样TCP是通过accept函数拿到的描述字信息来决定对端的信息。另外UDP报文每次接收都会获取对端的信息也就是说报文和报文之间是没有上下文的。
函数的返回值告诉我们实际接收的字节数。
接下来看一下sendto函数。
sendto函数中的前三个参数为sockfd、buff和nbytes。sockfd是本地创建的套接字描述符buff指向发送的缓存nbytes表示发送字节数。第四个参数flags依旧设置为0。
后面两个参数to和addrlen表示发送的对端地址和端口等信息。
函数的返回值告诉我们实际发送的字节数。
我们知道, TCP的发送和接收每次都是在一个上下文中类似这样的过程
A连接上: 接收→发送→接收→发送→…
B连接上: 接收→发送→接收→发送→ …
而UDP的每次接收和发送都是一个独立的上下文类似这样
接收A→发送A→接收B→发送B →接收C→发送C→ …
## UDP服务端例子
我们先来看一个UDP服务器端的例子
```
#include &quot;lib/common.h&quot;
static int count;
static void recvfrom_int(int signo) {
printf(&quot;\nreceived %d datagrams\n&quot;, count);
exit(0);
}
int main(int argc, char **argv) {
int socket_fd;
socket_fd = socket(AF_INET, SOCK_DGRAM, 0);
struct sockaddr_in server_addr;
bzero(&amp;server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_port = htons(SERV_PORT);
bind(socket_fd, (struct sockaddr *) &amp;server_addr, sizeof(server_addr));
socklen_t client_len;
char message[MAXLINE];
count = 0;
signal(SIGINT, recvfrom_int);
struct sockaddr_in client_addr;
client_len = sizeof(client_addr);
for (;;) {
int n = recvfrom(socket_fd, message, MAXLINE, 0, (struct sockaddr *) &amp;client_addr, &amp;client_len);
message[n] = 0;
printf(&quot;received %d bytes: %s\n&quot;, n, message);
char send_line[MAXLINE];
sprintf(send_line, &quot;Hi, %s&quot;, message);
sendto(socket_fd, send_line, strlen(send_line), 0, (struct sockaddr *) &amp;client_addr, client_len);
count++;
}
}
```
程序的1213行首先创建一个套接字注意这里的套接字类型是“SOCK_DGRAM”表示的是UDP数据报。
1521行和TCP服务器端类似绑定数据报套接字到本地的一个端口上。
27行为该服务器创建了一个信号处理函数以便在响应“Ctrl+C”退出时打印出收到的报文总数。
3142行是该服务器端的主体通过调用recvfrom函数获取客户端发送的报文之后我们对收到的报文进行重新改造加上“Hi”的前缀再通过sendto函数发送给客户端对端。
## UDP客户端例子
接下来我们再来构建一个对应的UDP客户端。在这个例子中从标准输入中读取输入的字符串后发送给服务端并且把服务端经过处理的报文打印到标准输出上。
```
#include &quot;lib/common.h&quot;
# define MAXLINE 4096
int main(int argc, char **argv) {
if (argc != 2) {
error(1, 0, &quot;usage: udpclient &lt;IPaddress&gt;&quot;);
}
int socket_fd;
socket_fd = socket(AF_INET, SOCK_DGRAM, 0);
struct sockaddr_in server_addr;
bzero(&amp;server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(SERV_PORT);
inet_pton(AF_INET, argv[1], &amp;server_addr.sin_addr);
socklen_t server_len = sizeof(server_addr);
struct sockaddr *reply_addr;
reply_addr = malloc(server_len);
char send_line[MAXLINE], recv_line[MAXLINE + 1];
socklen_t len;
int n;
while (fgets(send_line, MAXLINE, stdin) != NULL) {
int i = strlen(send_line);
if (send_line[i - 1] == '\n') {
send_line[i - 1] = 0;
}
printf(&quot;now sending %s\n&quot;, send_line);
size_t rt = sendto(socket_fd, send_line, strlen(send_line), 0, (struct sockaddr *) &amp;server_addr, server_len);
if (rt &lt; 0) {
error(1, errno, &quot;send failed &quot;);
}
printf(&quot;send bytes: %zu \n&quot;, rt);
len = 0;
n = recvfrom(socket_fd, recv_line, MAXLINE, 0, reply_addr, &amp;len);
if (n &lt; 0)
error(1, errno, &quot;recvfrom failed&quot;);
recv_line[n] = 0;
fputs(recv_line, stdout);
fputs(&quot;\n&quot;, stdout);
}
exit(0);
}
```
1011行创建一个类型为“SOCK_DGRAM”的套接字。
1317行初始化目标服务器的地址和端口。
2851行为程序主体从标准输入中读取的字符进行处理后调用sendto函数发送给目标服务器端然后再次调用recvfrom函数接收目标服务器发送过来的新报文并将其打印到标准输出上。
为了让你更好地理解UDP和TCP之间的差别我们模拟一下UDP的三种运行场景你不妨思考一下这三种场景的结果和TCP的到底有什么不同
## 场景一:只运行客户端
如果我们只运行客户端程序会一直阻塞在recvfrom上。
```
$ ./udpclient 127.0.0.1
1
now sending g1
send bytes: 2
&lt;阻塞在这里&gt;
```
还记得TCP程序吗如果不开启服务端TCP客户端的connect函数会直接返回“Connection refused”报错信息。而在UDP程序里则会一直阻塞在这里。
## 场景二:先开启服务端,再开启客户端
在这个场景里,我们先开启服务端在端口侦听,然后再开启客户端:
```
$./udpserver
received 2 bytes: g1
received 2 bytes: g2
```
```
$./udpclient 127.0.0.1
g1
now sending g1
send bytes: 2
Hi, g1
g2
now sending g2
send bytes: 2
Hi, g2
```
我们在客户端一次输入g1、g2服务器端在屏幕上打印出收到的字符并且可以看到我们的客户端也收到了服务端的回应“Hi,g1”和“Hi,g2”。
## 场景三: 开启服务端,再一次开启两个客户端
这个实验中,在服务端开启之后,依次开启两个客户端,并发送报文。
服务端:
```
$./udpserver
received 2 bytes: g1
received 2 bytes: g2
received 2 bytes: g3
received 2 bytes: g4
```
第一个客户端:
```
$./udpclient 127.0.0.1
now sending g1
send bytes: 2
Hi, g1
g3
now sending g3
send bytes: 2
Hi, g3
```
第二个客户端:
```
$./udpclient 127.0.0.1
now sending g2
send bytes: 2
Hi, g2
g4
now sending g4
send bytes: 2
Hi, g4
```
我们看到,两个客户端发送的报文,依次都被服务端收到,并且客户端也可以收到服务端处理之后的报文。
如果我们此时把服务器端进程杀死,就可以看到信号函数在进程退出之前,打印出服务器端接收到的报文个数。
```
$ ./udpserver
received 2 bytes: g1
received 2 bytes: g2
received 2 bytes: g3
received 2 bytes: g4
^C
received 4 datagrams
```
之后我们再重启服务器端进程并使用客户端1和客户端2继续发送新的报文我们可以看到和TCP非常不同的结果。
以下就是服务器端的输出服务器端重启后可以继续收到客户端的报文这在TCP里是不可以的TCP断联之后必须重新连接才可以发送报文信息。但是UDP报文的“无连接”的特点可以在UDP服务器重启之后继续进行报文的发送这就是UDP报文“无上下文”的最好说明。
```
$ ./udpserver
received 2 bytes: g1
received 2 bytes: g2
received 2 bytes: g3
received 2 bytes: g4
^C
received 4 datagrams
$ ./udpserver
received 2 bytes: g5
received 2 bytes: g6
```
第一个客户端:
```
$./udpclient 127.0.0.1
now sending g1
send bytes: 2
Hi, g1
g3
now sending g3
send bytes: 2
Hi, g3
g5
now sending g5
send bytes: 2
Hi, g5
```
第二个客户端:
```
$./udpclient 127.0.0.1
now sending g2
send bytes: 2
Hi, g2
g4
now sending g4
send bytes: 2
Hi, g4
g6
now sending g6
send bytes: 2
Hi, g6
```
## 总结
在这一讲里我介绍了UDP程序的例子我们需要重点关注以下两点
- UDP是无连接的数据报程序和TCP不同不需要三次握手建立一条连接。
- UDP程序通过recvfrom和sendto函数直接收发数据报报文。
## 思考题
最后给你留两个思考题吧。在第一个场景中recvfrom一直处于阻塞状态中这是非常不合理的你觉得这种情形应该怎么处理呢另外既然UDP是请求-应答模式的那么请求中的UDP报文最大可以是多大呢
欢迎你在评论区写下你的思考我会和你一起讨论。也欢迎把这篇文章分享给你的朋友或者同事一起讨论一下UDP这个协议。

View File

@@ -0,0 +1,424 @@
<audio id="audio" title="07 | What? 还有本地套接字?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/07/fc/0732f7b5f140e400a6742884e65fadfc.mp3"></audio>
你好我是盛延敏这里是网络编程实战第7讲欢迎回来。
上一篇文章中我们讲了UDP。很多同学都知道TCP和UDP但是对本地套接字却不甚了解。
实际上本地套接字是IPC也就是本地进程间通信的一种实现方式。除了本地套接字以外其它技术诸如管道、共享消息队列等也是进程间通信的常用方法但因为本地套接字开发便捷接受度高所以普遍适用于在同一台主机上进程间通信的各种场景。
那么今天我们就来学习下本地套接字方面的知识,并且利用本地套接字完成可靠字节流和数据报两种协议。
## 从例子开始
现在最火的云计算技术是什么无疑是Kubernetes和Docker。在Kubernetes和Docker的技术体系中有很多优秀的设计比如Kubernetes的CRIContainer Runtime Interface其思想是将Kubernetes的主要逻辑和Container Runtime的实现解耦。
我们可以通过netstat命令查看Linux系统内的本地套接字状况下面这张图列出了路径为/var/run/dockershim.socket的stream类型的本地套接字可以清楚地看到开启这个套接字的进程为kubelet。kubelet是Kubernetes的一个组件这个组件负责将控制器和调度器的命令转化为单机上的容器实例。为了实现和容器运行时的解耦kubelet设计了基于本地套接字的客户端-服务器GRPC调用。
<img src="https://static001.geekbang.org/resource/image/c7/6b/c75a8467a84f30e523917f28f2f4266b.jpg" alt=""><br>
眼尖的同学可能发现列表里还有docker-containerd.sock等其他本地套接字是的Docker其实也是大量使用了本地套接字技术来构建的。
如果我们在/var/run目录下将会看到docker使用的本地套接字描述符:
<img src="https://static001.geekbang.org/resource/image/a0/4d/a0e6f8ca0f9c5727f554323a26a9c14d.jpg" alt="">
## 本地套接字概述
本地套接字一般也叫做UNIX域套接字最新的规范已经改叫本地套接字。在前面的TCP/UDP例子中我们经常使用127.0.0.1完成客户端进程和服务器端进程同时在本机上的通信,那么,这里的本地套接字又是什么呢?
本地套接字是一种特殊类型的套接字和TCP/UDP套接字不同。TCP/UDP即使在本地地址通信也要走系统网络协议栈而本地套接字严格意义上说提供了一种单主机跨进程间调用的手段减少了协议栈实现的复杂度效率比TCP/UDP套接字都要高许多。类似的IPC机制还有UNIX管道、共享内存和RPC调用等。
比如X Window实现如果发现是本地连接就会走本地套接字工作效率非常高。
现在你可以回忆一下,在前面介绍套接字地址时,我们讲到了本地地址,这个本地地址就是本地套接字专属的。
<img src="https://static001.geekbang.org/resource/image/ed/58/ed49b0f1b658e82cb07a6e1e81f36b58.png" alt="">
## 本地字节流套接字
我们先从字节流本地套接字开始。
这是一个字节流类型的本地套接字服务器端例子。在这个例子中,服务器程序打开本地套接字后,接收客户端发送来的字节流,并往客户端回送了新的字节流。
```
#include &quot;lib/common.h&quot;
int main(int argc, char **argv) {
if (argc != 2) {
error(1, 0, &quot;usage: unixstreamserver &lt;local_path&gt;&quot;);
}
int listenfd, connfd;
socklen_t clilen;
struct sockaddr_un cliaddr, servaddr;
listenfd = socket(AF_LOCAL, SOCK_STREAM, 0);
if (listenfd &lt; 0) {
error(1, errno, &quot;socket create failed&quot;);
}
char *local_path = argv[1];
unlink(local_path);
bzero(&amp;servaddr, sizeof(servaddr));
servaddr.sun_family = AF_LOCAL;
strcpy(servaddr.sun_path, local_path);
if (bind(listenfd, (struct sockaddr *) &amp;servaddr, sizeof(servaddr)) &lt; 0) {
error(1, errno, &quot;bind failed&quot;);
}
if (listen(listenfd, LISTENQ) &lt; 0) {
error(1, errno, &quot;listen failed&quot;);
}
clilen = sizeof(cliaddr);
if ((connfd = accept(listenfd, (struct sockaddr *) &amp;cliaddr, &amp;clilen)) &lt; 0) {
if (errno == EINTR)
error(1, errno, &quot;accept failed&quot;); /* back to for() */
else
error(1, errno, &quot;accept failed&quot;);
}
char buf[BUFFER_SIZE];
while (1) {
bzero(buf, sizeof(buf));
if (read(connfd, buf, BUFFER_SIZE) == 0) {
printf(&quot;client quit&quot;);
break;
}
printf(&quot;Receive: %s&quot;, buf);
char send_line[MAXLINE];
sprintf(send_line, &quot;Hi, %s&quot;, buf);
int nbytes = sizeof(send_line);
if (write(connfd, send_line, nbytes) != nbytes)
error(1, errno, &quot;write error&quot;);
}
close(listenfd);
close(connfd);
exit(0);
}
```
我对这个程序做一个详细的解释:
- 第1215行非常关键**这里创建的套接字类型注意是AF_LOCAL并且使用字节流格式**。你现在可以回忆一下TCP的类型是AF_INET和字节流类型UDP的类型是AF_INET和数据报类型。在前面的文章中我们提到AF_UNIX也是可以的基本上可以认为和AF_LOCAL是等价的。
- 第1721行创建了一个本地地址这里的本地地址和IPv4、IPv6地址可以对应数据类型为sockaddr_un这个数据类型中的sun_family需要填写为AF_LOCAL最为关键的是需要对sun_path设置一个本地文件路径。我们这里还做了一个unlink操作以便把存在的文件删除掉这样可以保持幂等性。
- 第2329行分别执行bind和listen操作这样就监听在一个本地文件路径标识的套接字上这和普通的TCP服务端程序没什么区别。
- 第4156行使用read和write函数从套接字中按照字节流的方式读取和发送数据。
我在这里着重强调一下本地文件路径。关于本地文件路径,需要明确一点,它必须是“绝对路径”,这样的话,编写好的程序可以在任何目录里被启动和管理。如果是“相对路径”,为了保持同样的目的,这个程序的启动路径就必须固定,这样一来,对程序的管理反而是一个很大的负担。
另外还要明确一点这个本地文件必须是一个“文件”不能是一个“目录”。如果文件不存在后面bind操作时会自动创建这个文件。
还有一点需要牢记在Linux下任何文件操作都有权限的概念应用程序启动时也有应用属主。如果当前启动程序的用户权限不能创建文件你猜猜会发生什么呢这里我先卖个关子一会演示的时候你就会看到结果。
下面我们再看一下客户端程序。
```
#include &quot;lib/common.h&quot;
int main(int argc, char **argv) {
if (argc != 2) {
error(1, 0, &quot;usage: unixstreamclient &lt;local_path&gt;&quot;);
}
int sockfd;
struct sockaddr_un servaddr;
sockfd = socket(AF_LOCAL, SOCK_STREAM, 0);
if (sockfd &lt; 0) {
error(1, errno, &quot;create socket failed&quot;);
}
bzero(&amp;servaddr, sizeof(servaddr));
servaddr.sun_family = AF_LOCAL;
strcpy(servaddr.sun_path, argv[1]);
if (connect(sockfd, (struct sockaddr *) &amp;servaddr, sizeof(servaddr)) &lt; 0) {
error(1, errno, &quot;connect failed&quot;);
}
char send_line[MAXLINE];
bzero(send_line, MAXLINE);
char recv_line[MAXLINE];
while (fgets(send_line, MAXLINE, stdin) != NULL) {
int nbytes = sizeof(send_line);
if (write(sockfd, send_line, nbytes) != nbytes)
error(1, errno, &quot;write error&quot;);
if (read(sockfd, recv_line, MAXLINE) == 0)
error(1, errno, &quot;server terminated prematurely&quot;);
fputs(recv_line, stdout);
}
exit(0);
}
```
下面我带大家理解一下这个客户端程序。
- 1114行创建了一个本地套接字和前面服务器端程序一样用的也是字节流类型SOCK_STREAM。
- 1618行初始化目标服务器端的地址。我们知道在TCP编程中使用的是服务器的IP地址和端口作为目标在本地套接字中则使用文件路径作为目标标识sun_path这个字段标识的是目标文件路径所以这里需要对sun_path进行初始化。
- 20行和TCP客户端一样发起对目标套接字的connect调用不过由于是本地套接字并不会有三次握手。
- 2838行从标准输入中读取字符串向服务器端发送之后将服务器端传输过来的字符打印到标准输出上。
总体上我们可以看到本地字节流套接字和TCP服务器端、客户端编程最大的差异就是套接字类型的不同。本地字节流套接字识别服务器不再通过IP地址和端口而是通过本地文件。
接下来,我们就运行这个程序来加深对此的理解。
### 只启动客户端
第一个场景中,我们只启动客户端程序:
```
$ ./unixstreamclient /tmp/unixstream.sock
connect failed: No such file or directory (2)
```
我们看到,由于没有启动服务器端,没有一个本地套接字在/tmp/unixstream.sock这个文件上监听客户端直接报错提示我们没有文件存在。
### 服务器端监听在无权限的文件路径上
还记得我们在前面卖的关子吗在Linux下执行任何应用程序都有应用属主的概念。在这里我们让服务器端程序的应用属主没有/var/lib/目录的权限,然后试着启动一下这个服务器程序
```
$ ./unixstreamserver /var/lib/unixstream.sock
bind failed: Permission denied (13)
```
这个结果告诉我们启动服务器端程序的用户,必须对本地监听路径有权限。这个结果和你期望的一致吗?
试一下root用户启动该程序
```
sudo ./unixstreamserver /var/lib/unixstream.sock
(阻塞运行中)
```
我们看到,服务器端程序正常运行了。
打开另外一个shell我们看到/var/lib下创建了一个本地文件大小为0而且文件的最后结尾有一个=号。其实这就是bind的时候自动创建出来的文件。
```
$ ls -al /var/lib/unixstream.sock
rwxr-xr-x 1 root root 0 Jul 15 12:41 /var/lib/unixstream.sock=
```
如果我们使用netstat命令查看UNIX域套接字就会发现unixstreamserver这个进程监听在/var/lib/unixstream.sock这个文件路径上。
<img src="https://static001.geekbang.org/resource/image/58/b1/58d259d15b7012645d168a9c5d9f3fb1.jpg" alt=""><br>
看看很简单吧我们写的程序和鼎鼎大名的Kubernetes运行在同一机器上原理和行为完全一致。
### 服务器-客户端应答
现在,我们让服务器和客户端都正常启动,并且客户端依次发送字符:
```
$./unixstreamserver /tmp/unixstream.sock
Receive: g1
Receive: g2
Receive: g3
client quit
```
```
$./unixstreamclient /tmp/unixstream.sock
g1
Hi, g1
g2
Hi, g2
g3
Hi, g3
^C
```
我们可以看到服务器端陆续收到客户端发送的字节同时客户端也收到了服务器端的应答最后当我们使用Ctrl+C让客户端程序退出时服务器端也正常退出。
## 本地数据报套接字
我们再来看下在本地套接字上使用数据报的服务器端例子:
```
#include &quot;lib/common.h&quot;
int main(int argc, char **argv) {
if (argc != 2) {
error(1, 0, &quot;usage: unixdataserver &lt;local_path&gt;&quot;);
}
int socket_fd;
socket_fd = socket(AF_LOCAL, SOCK_DGRAM, 0);
if (socket_fd &lt; 0) {
error(1, errno, &quot;socket create failed&quot;);
}
struct sockaddr_un servaddr;
char *local_path = argv[1];
unlink(local_path);
bzero(&amp;servaddr, sizeof(servaddr));
servaddr.sun_family = AF_LOCAL;
strcpy(servaddr.sun_path, local_path);
if (bind(socket_fd, (struct sockaddr *) &amp;servaddr, sizeof(servaddr)) &lt; 0) {
error(1, errno, &quot;bind failed&quot;);
}
char buf[BUFFER_SIZE];
struct sockaddr_un client_addr;
socklen_t client_len = sizeof(client_addr);
while (1) {
bzero(buf, sizeof(buf));
if (recvfrom(socket_fd, buf, BUFFER_SIZE, 0, (struct sockadd *) &amp;client_addr, &amp;client_len) == 0) {
printf(&quot;client quit&quot;);
break;
}
printf(&quot;Receive: %s \n&quot;, buf);
char send_line[MAXLINE];
bzero(send_line, MAXLINE);
sprintf(send_line, &quot;Hi, %s&quot;, buf);
size_t nbytes = strlen(send_line);
printf(&quot;now sending: %s \n&quot;, send_line);
if (sendto(socket_fd, send_line, nbytes, 0, (struct sockadd *) &amp;client_addr, client_len) != nbytes)
error(1, errno, &quot;sendto error&quot;);
}
close(socket_fd);
exit(0);
}
```
本地数据报套接字和前面的字节流本地套接字有以下几点不同:
- 第9行创建的本地套接字**这里创建的套接字类型注意是AF_LOCAL**协议类型为SOCK_DGRAM。
- 2123行bind到本地地址之后没有再调用listen和accept回忆一下这其实和UDP的性质一样。
- 2845行使用recvfrom和sendto来进行数据报的收发不再是read和send这其实也和UDP网络程序一致。
然后我们再看一下客户端的例子:
```
#include &quot;lib/common.h&quot;
int main(int argc, char **argv) {
if (argc != 2) {
error(1, 0, &quot;usage: unixdataclient &lt;local_path&gt;&quot;);
}
int sockfd;
struct sockaddr_un client_addr, server_addr;
sockfd = socket(AF_LOCAL, SOCK_DGRAM, 0);
if (sockfd &lt; 0) {
error(1, errno, &quot;create socket failed&quot;);
}
bzero(&amp;client_addr, sizeof(client_addr)); /* bind an address for us */
client_addr.sun_family = AF_LOCAL;
strcpy(client_addr.sun_path, tmpnam(NULL));
if (bind(sockfd, (struct sockaddr *) &amp;client_addr, sizeof(client_addr)) &lt; 0) {
error(1, errno, &quot;bind failed&quot;);
}
bzero(&amp;server_addr, sizeof(server_addr));
server_addr.sun_family = AF_LOCAL;
strcpy(server_addr.sun_path, argv[1]);
char send_line[MAXLINE];
bzero(send_line, MAXLINE);
char recv_line[MAXLINE];
while (fgets(send_line, MAXLINE, stdin) != NULL) {
int i = strlen(send_line);
if (send_line[i - 1] == '\n') {
send_line[i - 1] = 0;
}
size_t nbytes = strlen(send_line);
printf(&quot;now sending %s \n&quot;, send_line);
if (sendto(sockfd, send_line, nbytes, 0, (struct sockaddr *) &amp;server_addr, sizeof(server_addr)) != nbytes)
error(1, errno, &quot;sendto error&quot;);
int n = recvfrom(sockfd, recv_line, MAXLINE, 0, NULL, NULL);
recv_line[n] = 0;
fputs(recv_line, stdout);
fputs(&quot;\n&quot;, stdout);
}
exit(0);
}
```
这个程序和UDP网络编程的例子基本是一致的我们可以把它当作是用本地文件替换了IP地址和端口的UDP程序不过这里还是有一个非常大的不同的。
这个不同点就在1622行。你可以看到1622行将本地套接字bind到本地一个路径上然而UDP客户端程序是不需要这么做的。本地数据报套接字这么做的原因是它需要指定一个本地路径以便在服务器端回包时可以正确地找到地址而在UDP客户端程序里数据是可以通过UDP包的本地地址和端口来匹配的。
下面这段代码就展示了服务器端和客户端通过数据报应答的场景:
```
./unixdataserver /tmp/unixdata.sock
Receive: g1
now sending: Hi, g1
Receive: g2
now sending: Hi, g2
Receive: g3
now sending: Hi, g3
```
```
$ ./unixdataclient /tmp/unixdata.sock
g1
now sending g1
Hi, g1
g2
now sending g2
Hi, g2
g3
now sending g3
Hi, g3
^C
```
我们可以看到,服务器端陆续收到客户端发送的数据报,同时,客户端也收到了服务器端的应答。
## 总结
我在开头已经说过,本地套接字作为常用的进程间通信技术,被用于各种适用于在同一台主机上进程间通信的场景。关于本地套接字,我们需要牢记以下两点:
- 本地套接字的编程接口和IPv4、IPv6套接字编程接口是一致的可以支持字节流和数据报两种协议。
- 本地套接字的实现效率大大高于IPv4和IPv6的字节流、数据报套接字实现。
## 思考题
讲完本地套接字之后,我给你留几道思考题。
1. 在本地套接字字节流类型的客户端-服务器例子中我们让服务器端以root账号启动监听在/var/lib/unixstream.sock这个文件上。如果我们让客户端以普通用户权限启动客户端可以连接上/var/lib/unixstream.sock吗为什么呢
1. 我们看到客户端被杀死后,服务器端也正常退出了。看下退出后打印的日志,你不妨判断一下引起服务器端正常退出的逻辑是什么?
1. 你有没有想过这样一个奇怪的场景如果自己不小心写错了代码本地套接字服务器端是SOCK_DGRAM客户端使用的是SOCK_STREAM路径和其他都是正确的你觉得会发生什么呢
欢迎你在评论区写下你的思考,我会和你一起交流这些问题。如果这篇文章帮你弄懂了本地套接字,不妨把它分享给你的朋友或者同事,一起交流一下它吧!

View File

@@ -0,0 +1,273 @@
<audio id="audio" title="08 | 工欲善其事必先利其器:学会使用各种工具" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/43/07/439fc648abb50ffd027d850573845f07.mp3"></audio>
你好我是盛延敏这里是网络编程实战第8讲欢迎回来。
上一讲我们讲到了本地套接字加上前面介绍的TCP、UDP套接字你会发现我们已经比较全面地接触了套接字。
其实在平常使用套接字开发和测试过程中我们总会碰到这样或那样的问题。学会对这些问题进行诊断和分析其实需要不断地积累经验。而Linux平台下提供的各种网络工具则为我们进行诊断分析提供了很好的帮助。在这一讲里我将会选择几个重点的工具逐一介绍。
## 必备工具: ping
这个命令我想大家都不陌生“ping”这个命名来自于声呐探测在网络上用来完成对网络连通性的探测这个命名可以说是恰如其分了。
```
$ ping www.sina.com.cn
PING www.sina.com.cn (202.102.94.124) 56(84) bytes of data.
64 bytes from www.sina.com.cn (202.102.94.124): icmp_seq=1 ttl=63 time=8.64 ms
64 bytes from www.sina.com.cn (202.102.94.124): icmp_seq=2 ttl=63 time=11.3 ms
64 bytes from www.sina.com.cn (202.102.94.124): icmp_seq=3 ttl=63 time=8.66 ms
64 bytes from www.sina.com.cn (202.102.94.124): icmp_seq=4 ttl=63 time=13.7 ms
64 bytes from www.sina.com.cn (202.102.94.124): icmp_seq=5 ttl=63 time=8.22 ms
64 bytes from www.sina.com.cn (202.102.94.124): icmp_seq=6 ttl=63 time=7.99 ms
^C
--- www.sina.com.cn ping statistics ---
6 packets transmitted, 6 received, 0% packet loss, time 5006ms
rtt min/avg/max/mdev = 7.997/9.782/13.795/2.112 ms
```
在上面的例子中我使用ping命令探测了和新浪网的网络连通性。可以看到每次显示是按照sequence序列号排序显示的一并显示的也包括TTLtime to live反映了两个IP地址之间传输的时间。最后还显示了ping命令的统计信息如最小时间、平均时间等。
我们需要经常和Linux下的ping命令打交道那么ping命令的原理到底是什么呢它是基于TCP还是UDP开发的
都不是。
其实ping是基于一种叫做ICMP的协议开发的ICMP又是一种基于IP协议的控制协议翻译为网际控制协议其报文格式如下图
<img src="https://static001.geekbang.org/resource/image/15/38/1555df944c00bdba5c2a4ea3c55cf338.png" alt=""><br>
ICMP在IP报文后加入了新的内容这些内容包括
- 类型即ICMP的类型, 其中ping的请求类型为8应答为0。
- 代码进一步划分ICMP的类型, 用来查找产生错误的原因。
- 校验和:用于检查错误的数据。
- 标识符通过标识符来确认是谁发送的控制协议可以是进程ID。
- 序列号唯一确定的一个报文前面ping名字执行后显示的icmp_seq就是这个值。
当我们发起ping命令时ping程序实际上会组装成如图的一个IP报文。报文的目的地址为ping的目标地址源地址就是发送ping命令时的主机地址同时按照ICMP报文格式填上数据在可选数据上可以填上发送时的时间戳。
IP报文通过ARP协议源地址和目的地址被翻译成MAC地址经过数据链路层后报文被传输出去。当报文到达目的地址之后目的地址所在的主机也按照ICMP协议进行应答。之所以叫做协议是因为双方都会遵守这个报文格式并且也会按照格式进行发送-应答。
应答数据到达源地址之后ping命令可以通过再次解析ICMP报文对比序列号计算时间戳等来完成每个发送-应答的显示,最终显示的格式就像前面的例子中展示的一样。
可以说ICMP协议为我们侦测网络问题提供了非常好的支持。另外一种对路由的检测命令Traceroute也是通过ICMP协议来完成的这里就不展开讲了。
## 基本命令: ifconfig
很多熟悉Windows的同学都知道Windows有一个ipconfig命令用来显示当前的网络设备列表。事实上Linux有一个对应的命令叫做ifconfig也用来显示当前系统中的所有网络设备通俗一点的说就是网卡列表。
```
vagrant@ubuntu-xenial-01:~$ ifconfig
cni0 Link encap:Ethernet HWaddr 0a:58:0a:f4:00:01
inet addr:10.244.0.1 Bcast:0.0.0.0 Mask:255.255.255.0
inet6 addr: fe80::401:b4ff:fe51:bcf9/64 Scope:Link
UP BROADCAST RUNNING MULTICAST MTU:1450 Metric:1
RX packets:2133 errors:0 dropped:0 overruns:0 frame:0
TX packets:2216 errors:0 dropped:0 overruns:0 carrier:0
collisions:0 txqueuelen:1000
RX bytes:139381 (139.3 KB) TX bytes:853302 (853.3 KB)
docker0 Link encap:Ethernet HWaddr 02:42:93:0f:f7:11
inet addr:172.17.0.1 Bcast:0.0.0.0 Mask:255.255.0.0
inet6 addr: fe80::42:93ff:fe0f:f711/64 Scope:Link
UP BROADCAST RUNNING MULTICAST MTU:1500 Metric:1
RX packets:653 errors:0 dropped:0 overruns:0 frame:0
TX packets:685 errors:0 dropped:0 overruns:0 carrier:0
collisions:0 txqueuelen:0
RX bytes:49542 (49.5 KB) TX bytes:430826 (430.8 KB)
enp0s3 Link encap:Ethernet HWaddr 02:54:ad:ea:60:2e
inet addr:10.0.2.15 Bcast:10.0.2.255 Mask:255.255.255.0
inet6 addr: fe80::54:adff:feea:602e/64 Scope:Link
UP BROADCAST RUNNING MULTICAST MTU:1500 Metric:1
RX packets:7951 errors:0 dropped:0 overruns:0 frame:0
TX packets:4123 errors:0 dropped:0 overruns:0 carrier:0
collisions:0 txqueuelen:1000
RX bytes:5081047 (5.0 MB) TX bytes:385600 (385.6 KB)
```
我稍微解释一下这里面显示的数据。
```
Link encap:Ethernet HWaddr 02:54:ad:ea:60:2e
```
上面这段表明这是一个以太网设备MAC地址为02:54:ad:ea:60:2e。
```
inet addr:10.0.2.15 Bcast:10.0.2.255 Mask:255.255.255.0
inet6 addr: fe80::54:adff:feea:602e/64 Scope:Link
```
这里显示的是网卡的IPv4和IPv6地址其中IPv4还显示了该网络的子网掩码以及广播地址。
在每个IPv4子网中有一个特殊地址被保留作为子网广播地址比如这里的10.0.2.255就是这个子网的广播地址。当向这个地址发送请求时,就会向以太网网络上的一组主机发送请求。
通常来说这种被称作广播broadcast的技术是用UDP来实现的。
```
UP BROADCAST RUNNING MULTICAST MTU:1500 Metric:1
```
这里显示的是网卡的状态MTU是最大传输单元的意思表示的是链路层包的大小。1500表示的是字节大小。
Metric大家可能不知道是干啥用的这里解释下Linux在一台主机上可以有多个网卡设备很可能有这么一种情况多个网卡可以路由到目的地。一个简单的例子是在同时有无线网卡和有线网卡的情况下网络连接是从哪一个网卡设备上出去的Metric就是用来确定多块网卡的优先级的数值越小优先级越高1为最高级。
```
RX packets:7951 errors:0 dropped:0 overruns:0 frame:0
TX packets:4123 errors:0 dropped:0 overruns:0 carrier:0
collisions:0 txqueuelen:1000
RX bytes:5081047 (5.0 MB) TX bytes:385600 (385.6 KB)
```
## netstat和lsof对网络状况了如指掌
在平时的工作中我们最常碰到的问题就是某某进程对应的网络状况如何是不是连接被打爆了还是有大量的TIME_WAIT连接
netstat可以帮助我们了解当前的网络连接状况比如我想知道当前所有的连接详情就可以使用下面这行命令
```
netstat -alepn
```
可能的结果为:
<img src="https://static001.geekbang.org/resource/image/34/df/34084af982a4c4223e0a78ed01c662df.jpg" alt=""><br>
netstat会把所有IPv4形态的TCPIPV6形态的TCP、UDP以及UNIX域的套接字都显示出来。
对于TCP类型来说最大的好处是可以清楚地看到一条TCP连接的四元组源地址、源端口、目的地地址和目的端口
例如这里的一条信息:
```
tcp 0 0 127.0.0.1:2379 127.0.0.1:52464 ESTABLISHED 0 27710 3496/etcd
```
它表达的意思是本地127.0.0.1的端口52464连上本地127.0.0.1的端口2379状态为ESTABLISHED本地进程为etcd进程为3496。
这在实战分析的时候非常有用比如你可以很方便地知道在某个时候是不是有很多TIME_WAIT的TCP连接导致端口号被占用光以致新的连接分配不了。
当然我们也可以只对UNIX套接字进行筛查。
```
netstat Socket -x -alepn
```
<img src="https://static001.geekbang.org/resource/image/a1/13/a1aeca1245b6b8cabaa0f22ce02d4813.jpg" alt=""><br>
UNIX套接字的结果稍有不同最关键的信息是Path这个信息显示了本地套接字监听的文件路径比如这条
```
unix 3 [ ] STREAM CONNECTED 23209 1400/dockerd /var/run/docker.sock
```
这其实就是大名鼎鼎的Docker在本地套接字的监听路径。/var/run/docker.sock是本地套接字监听地址dockerd是进程名称1400是进程号。
netstat命令可以选择的参数非常之多这里只关注了几个简单的场景你可以通过帮助命令或者查阅文档获得更多的信息。
lsof的常见用途之一是帮助我们找出在指定的IP地址或者端口上打开套接字的进程而netstat则告诉我们IP地址和端口使用的情况以及各个TCP连接的状态。Isof和netstst可以结合起来一起使用。
比如说我们可以通过lsof查看到底是谁打开了这个文件
```
lsof /var/run/docker.sock
```
下面这张图显示了是dockerd打开了这个本地文件套接字
<img src="https://static001.geekbang.org/resource/image/ac/28/acebeb7d0bbe26b469a200456c299d28.jpg" alt=""><br>
lsof还有一个非常常见的用途。如果我们启动了一个服务器程序发现这个服务器需要绑定的端口地址已经被占用内核报出“该地址已在使用”的出错信息我们可以使用lsof找出正在使用该端口的那个进程。比如下面这个代码就帮我们找到了使用8080端口的那个进程从而帮助我们定位问题。
```
lsof -i :8080
```
## 抓包利器: tcpdump
tcpdump这样的抓包工具对于网络编程而言是非常有用的特别是在一些“山重水复疑无路”的情形下通过tcpdump这样的抓包工具往往可以达到“柳暗花明又一村”的效果。
tcpdump具有非常强大的过滤和匹配功能。
比如说指定网卡:
```
tcpdump -i eth0
```
再比如说指定来源:
```
tcpdump src host hostname
```
我们再来一个复杂一点的例子。这里抓的包是TCP且端口是80包来自IP地址为192.168.1.25的主机地址。
```
tcpdump 'tcp and port 80 and src host 192.168.1.25'
```
如果我们对TCP协议非常熟悉还可以写出这样的tcpdump命令
```
tcpdump 'tcp and port 80 and tcp[13:1]&amp;2 != 0'
```
这里tcp[13:1]表示的是TCP头部开始处偏移为13的字节如果这个值为2说明设置了SYN分节当然我们也可以设置成其他值来获取希望类型的分节。注意这里的偏移是从0开始算起的tcp[13]其实是报文里的第14个字节。
tcpdump在开启抓包的时候会自动创建一个类型为AF_PACKET的网络套接口并向系统内核注册。当网卡接收到一个网络报文之后它会遍历系统中所有已经被注册的网络协议包括其中已经注册了的AF_PACKET网络协议。系统内核接下来就会将网卡收到的报文发送给该协议的回调函数进行一次处理回调函数可以把接收到的报文完完整整地复制一份假装是自己接收到的报文然后交给tcpdump程序进行各种条件的过滤和判断再对报文进行解析输出。
下面这张图显示的是tcpdump的输出格式
<img src="https://static001.geekbang.org/resource/image/43/c3/43a9e4ea08bc872c2646453ce06ed3c3.jpg" alt=""><br>
首先我们看到的是时间戳之后类似192.168.33.11.41388 &gt; 192.168.33.11.6443这样的显示的是源地址192.168.33.11.41388到目的地址192.168.33.11.6443然后Flags [ ]是包的标志,[P]表示是数据推送,比较常见的包格式如下:
- [S]SYN表示开始连接
- [.]:没有标记,一般是确认
- [P]PSH表示数据推送
- [F]FIN表示结束连接
- [R] RST表示重启连接
我们可以看到最后有几个数据,它们代表的含义如下:
- seq包序号就是TCP的确认分组
- cksum校验码
- win滑动窗口大小
- length承载的数据payload长度length如果没有数据则为0
此外tcpdump还可以对每条TCP报文的细节进行显示让我们可以看到每条报文的详细字节信息。这在对报文进行排查的时候很有用。
## 小结
本章我讲述了一些常见的网络诊断工具,这些工具需要你了解之后活学活用。用好它们,对加深网络编程的理解,以及对问题情况进行排查等都有非常大的帮助。
我再来总结一下这几个命令的作用:
- ping可以用来帮助我们进行网络连通性的探测。
- ifconfig用来显示当前系统中的所有网络设备。
- netstat和lsof可以查看活动的连接状况。
- tcpdump可以对各种奇怪的环境进行抓包进而帮我们了解报文排查问题。
## 思考题
最后给大家留两个思考题。
本章我讲到了强大的抓包工具tcpdump你知道tcpdump这个工具还可以对UDP包进行抓包处理吗你不妨尝试一下。
另外netstat输出时监听状态的套接字所对应的Foreign Address显示的*.*表示的是什么意思呢?
欢迎你在评论区写下你的思考,我会和你一起交流,也欢迎你把这篇文章分享给你的朋友或者同事,一起讨论下这几个工具。

View File

@@ -0,0 +1,218 @@
<audio id="audio" title="09丨答疑篇学习网络编程前需要准备哪些东西" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/45/2f/45e5cbc5292b5391f39846f45ed7ed2f.mp3"></audio>
你好我是盛延敏这里是网络编程实战第9讲欢迎回来。
今天是基础篇的最后一讲。在这一讲中,我将会针对基础篇中大家提出的普遍问题进行总结和答疑,让我们整理一下,再接着学习下一个模块的内容。
## 代码和环境
既然我希望通过学习,可以带你进行网络编程实战,那么就得有一个环境,可以运行文章中的例子,并加以活学活用。
我已经将代码上传到GitHub中你可以访问以下地址来获得最新的代码。
[https://github.com/froghui/yolanda](https://github.com/froghui/yolanda)
代码按照章节组织比如chap-7就对应第七篇文章。
代码按照CMake组织CMake是一个跨平台的编译管理系统使用CMake可以方便地在Linux等类UNIX系统下动态生成Makefile再由make工具编译、链接生成二进制文件。当然CMake也可以支持Windows系统下的C/C++编译,这里我们就不展开了。
所有的代码我都已经测试过可以运行在Linux和MacOS上。
### Ubuntu系统
在Linux下如果你是Ubuntu系统需要安装Cmake、make和gcc/g++等编译系统和工具。
```
sudo apt-get install gcc g++ make cmake
```
如果是CentOS或Red Hat需要执行yum install命令
```
sudo yum install gcc g++ make cmake
```
使用CMake编译程序需要两步第一步执行Cmake生成配置文件主要是Makefile具体做法是执行如下的cmake命令之后在build目录下会发现CMake根据系统环境如编译器、头文件等自动生成了一份Makefile
```
cd build &amp;&amp; cmake -f ../
```
接下来执行第二步在build目录运行make让make驱动gcc编译、链接生成二进制可执行程序这个过程可能会持续几分钟。最后在build/bin目录下会生成所有可运行的二进制程序。
```
-rwxr-xr-x 1 vagrant vagrant 13944 Aug 18 13:45 addressused*
-rwxr-xr-x 1 vagrant vagrant 14000 Aug 18 13:45 addressused02*
-rwxr-xr-x 1 vagrant vagrant 13848 Aug 18 13:45 batchwrite*
-rwxr-xr-x 1 vagrant vagrant 13800 Aug 18 13:45 bufferclient*
-rwxr-xr-x 1 vagrant vagrant 14192 Aug 18 13:45 graceclient*
-rwxr-xr-x 1 vagrant vagrant 14096 Aug 18 13:45 graceserver*
-rwxr-xr-x 1 vagrant vagrant 8960 Aug 18 13:45 make_socket*
-rwxr-xr-x 1 vagrant vagrant 13920 Aug 18 13:45 pingclient*
-rwxr-xr-x 1 vagrant vagrant 14176 Aug 18 13:45 pingserver*
-rwxr-xr-x 1 vagrant vagrant 13976 Aug 18 13:45 reliable_client01*
-rwxr-xr-x 1 vagrant vagrant 13832 Aug 18 13:45 reliable_client02*
-rwxr-xr-x 1 vagrant vagrant 14120 Aug 18 13:45 reliable_server01*
-rwxr-xr-x 1 vagrant vagrant 14040 Aug 18 13:45 reliable_server02*
-rwxr-xr-x 1 vagrant vagrant 14136 Aug 18 13:45 samplebuffer01*
-rwxr-xr-x 1 vagrant vagrant 13864 Aug 18 13:45 samplebuffer02*
-rwxr-xr-x 1 vagrant vagrant 14392 Aug 18 13:45 samplebuffer03*
-rwxr-xr-x 1 vagrant vagrant 13848 Aug 18 13:45 streamclient*
-rwxr-xr-x 1 vagrant vagrant 14392 Aug 18 13:45 streamserver*
-rwxr-xr-x 1 vagrant vagrant 13784 Aug 18 13:45 tcpclient*
-rwxr-xr-x 1 vagrant vagrant 13856 Aug 18 13:45 tcpserver*
-rwxr-xr-x 1 vagrant vagrant 13936 Aug 18 13:45 udpclient*
-rwxr-xr-x 1 vagrant vagrant 13320 Aug 18 13:45 udpserver*
-rwxr-xr-x 1 vagrant vagrant 13936 Aug 18 13:45 unixdataclient*
-rwxr-xr-x 1 vagrant vagrant 13896 Aug 18 13:45 unixdataserver*
-rwxr-xr-x 1 vagrant vagrant 13800 Aug 18 13:45 unixstreamclient*
-rwxr-xr-x 1 vagrant vagrant 13992 Aug 18 13:45 unixstreamserver*
```
### MacOS
在MacOS上Cmake和make都会有Mac特定版本并且实现的原理也是基本一致的我们可以像上面Ubuntu系统一样手动安装、配置这些工具。
如果你的系统上没有这两个软件可以使用brew安装Cmake和make。
```
brew install cmake
brew install make
```
MacOS上C/C++语言的编译器不同于GNU-GCC是一个叫做Clang的东西。Clang 背后的技术叫做LLVMLow Level Virtual Machine。LLVM 是以 BSD License开发的开源编译器框架系统基于 C++ 编写而成不仅可以支持C/C++还可以支持Swift、Rust等语言。
如果你在MaxOS上查看Clang的版本信息可以很明显地看到Clang是基于LLVM开发的并且对应的版本是多少。在我的机器上显示的LLVM版本是 10.0.0。
```
clang -v
Apple LLVM version 10.0.0 (clang-1000.10.44.4)
Target: x86_64-apple-darwin17.7.0
Thread model: posix
InstalledDir: /Library/Developer/CommandLineTools/usr/bin
```
下面是在MacOS上执行Cmake和make使用Clang完成编译和链接的过程。
```
cd build &amp;&amp; cmake -f ../
-- The C compiler identification is AppleClang 10.0.0.10001044
-- The CXX compiler identification is AppleClang 10.0.0.10001044
-- Check for working C compiler: /Library/Developer/CommandLineTools/usr/bin/cc
-- Check for working C compiler: /Library/Developer/CommandLineTools/usr/bin/cc -- works
-- Detecting C compiler ABI info
-- Detecting C compiler ABI info - done
-- Detecting C compile features
-- Detecting C compile features - done
-- Check for working CXX compiler: /Library/Developer/CommandLineTools/usr/bin/c++
-- Check for working CXX compiler: /Library/Developer/CommandLineTools/usr/bin/c++ -- works
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Configuring done
-- Generating done
-- Build files have been written to: /Users/shengym/Code/network/yolanda/test
```
```
cd build &amp;&amp; make
```
可执行程序仍然保存在 build/bin目录下面。
### CLion
对于有IDE情结的同学来说推荐使用JetBrains公司出品的CLion进行编译和调试。
你可以在这里下载 [https://www.jetbrains.com/clion/](https://www.jetbrains.com/clion/) 获得30天的免费使用。
CLion自带了CMake等工具可以开箱即用。它最强大的地方是可以直接设置断点方便进行调试。我们进入主菜单选择Run再选择Debug就可以启动程序进行调试。
<img src="https://static001.geekbang.org/resource/image/28/25/28c40e2422c01340a8ab8b368355f825.png" alt=""><br>
有些情况下启动程序时需要输入一些参数这个时候需要使用“Edit Configurations”为可执行程序配置参数。下面是一个例子
<img src="https://static001.geekbang.org/resource/image/61/5c/6191b86211dd13df6613bdb3481d8d5c.png" alt="">
## Windows
使用 Windows 系统 + CLion 的同学,可以在 Win10 应用商店中下载一个 Ubuntu 版本的 Windows 子系统,即 WSL然后在 CLion 中配置工程的环境为 WSL 即可编译运行。
## 学习路径
也许你刚刚入门,一直对网络编程的学习路径有很大的困惑,我在这里统一回复一下。
我觉得学习网络编程技术必须要过一道语言关这个语言就是C语言。专栏本身也是基于C语言的。虽然现代工业化语言如Java、Golang等已经足够强大但是你要知道在这些语言的背后无一例外的总是有C语言的影子。C语言是可以和系统直接交互的语言无论是系统调用还是内核实现都和C语言有非常直接的联系比如Java本身就是用C++实现的Golang虽然现在可以自举也就是可以使用Golang实现Golang本身但是它的第一版也是用C/C++实现的。
我不建议一开始就学习C++语言,在我看来, C++语言在C语言原来的基础上做了很多语言层面的增强而这些增强的语言特性例如模板、继承、虚函数、boost语言库等对于刚开始接触底层的人显得有些艰深。
学习一门编程语言显然不是学习这门语言的控制流或者变量类型而是抓住这门语言的精髓。我认为C语言的精髓包括数组和指针、结构体和函数。
C语言的地址、数组、指针可以帮助我们详细地理解计算机的体系结构一段数据怎样在内存中摆放怎么去访问等你可以在学习它们的过程中得到锤炼了解这些基础的编程理念。
有些同学一上来就啃“TCP/IP协议”我觉得对于实战来说显得过于着急。我们可以把“TCP/IP协议”当作编程过程中答疑解惑的好帮手有问题之后再从中寻找答案而不是急急忙忙就来啃这类书籍。说实话这类书籍理论性偏强有时候大段读下来也少有收获。
最好的办法,还是自己跟随一些入门书籍,或者我的这篇实战,尝试动手去写、去调试代码,这中间你会不断获得一些反馈,然后再和大家一起探讨,不断加深了解。
当你学到了一定阶段就可以给自己开一些小的任务比如写一个聊天室程序或者写一个HTTP服务器端程序带着任务去学习获得成就感的同时对网络编程的理解也随之更上一层楼了。
## 书籍推荐
我希望你可以通过这个专栏更好地了解网络编程,但是深入的学习还需要你自行去找更多的资料。我在这里给你推荐一些书,这些书是各个领域的经典。
C语言入门方面我推荐 《C程序设计语言》这里是豆瓣链接你可以看下大家的评价以及他们的学习方式 [https://book.douban.com/subject/1139336/](https://book.douban.com/subject/1139336/)
UNIX网络编程方面强烈推荐Stevens大神的两卷本《UNIX网络编程》其中第一卷是讲套接字的第二卷是讲IPC进程间通信的。这套书也随书配备了源代码你如果有兴趣的话可以对代码进行改写和调试。
豆瓣链接在此: [https://book.douban.com/subject/1500149/](https://book.douban.com/subject/1500149/)
这套书的卷一基本上面面俱到地讲述了UNIX网络编程的方方面面但有时候稍显啰嗦特别是高性能高并发这块已经跟不上时代但你可以把注意力放在卷一的前半部分。
这套书翻译了好几版,就我的体验来说,比较推荐杨继张翻译的版本。
TCP/IP协议方面当然是推荐Stevens的大作《TCP/IP详解》, 这套书总共有三卷第一卷讲协议第二卷讲实现第三卷讲TCP事务。我在这里推荐第一卷第二卷的实现是基于BSD的代码讲解的就不推荐了。我想如果你想看源码的话还是推荐看Linux的毕竟我们用的比较多。第三卷涉及的内容比较少见也不推荐了。
这套书各个出版社翻译了好多版本,你可以去豆瓣自行查看哪个版本评分比较高。
《TCP/IP详解 卷1协议》豆瓣链接如下
[https://book.douban.com/subject/1088054/](https://book.douban.com/subject/1088054/)
最后除了书籍外还有一个非常好的了解TCP的方法那就是查看RFC文档对于有一定英文能力的同学来说可以说是一个捷径。RFC最大的好处可以帮我们了解TCP发展的背景和脉络。
## 疑难解答
前面的内容算是我对你学习网络编程提供的一些小建议或者小帮助。接下来,我们正式进入到文章本身的内容。
在第5讲思考题部分中我出了这么一道题目“一段数据流从应用程序发送端一直到应用程序接收端总共经过了多少次拷贝”大家的回答五花八门。
我的本意可以用一张图来表示还记得TCP/IP层次模型么我想通过这么一个问题来展示TCP/IP分层的思想。
<img src="https://static001.geekbang.org/resource/image/50/8f/50c05c8509a1d8436273adbf8701bb8f.png" alt=""><br>
让我们先看发送端当应用程序将数据发送到发送缓冲区时调用的是send或write方法如果缓存中没有空间系统调用就会失败或者阻塞。我们说这个动作事实上是一次“显式拷贝”。而在这之后数据将会按照TCP/IP的分层再次进行拷贝这层的拷贝对我们来说就不是显式的了。
接下来轮到TCP协议栈工作创建Packet报文并把报文发送到传输队列中qdisc传输队列是一个典型的 FIFO 队列,队列的最大值可以通过 ifconfig 命令输出的 txqueuelen 来查看。通常情况下,这个值有几千报文大小。
TX ring 在网络驱动和网卡之间,也是一个传输请求的队列。
网卡作为物理设备工作在物理层,主要工作是把要发送的报文保存到内部的缓存中,并发送出去。
接下来再看接收端,报文首先到达网卡,由网卡保存在自己的接收缓存中,接下来报文被发送至网络驱动和网卡之间的 RX ring网络驱动从 RX ring 获取报文 ,然后把报文发送到上层。
这里值得注意的是,网络驱动和上层之间没有缓存,因为网络驱动使用 Napi 进行数据传输。因此,可以认为上层直接从 RX ring 中读取报文。
最后,报文的数据保存在套接字接收缓存中,应用程序从套接字接收缓存中读取数据。
这就是数据流从应用程序发送端,一直到应用程序接收端的整个过程,你看懂了吗?
上面的任何一个环节稍有积压,都会对程序性能产生影响。但好消息是,内核和网络设备供应商已经帮我们把一切都打点好了,我们看到和用到的,其实只是冰山上的一角而已。
这就是基础篇的总结与答疑部分,我先对之前基础篇的内容补充了一些资料,尽可能地为你学习网络编程提供方便,然后针对大家有明显疑惑的问题进行了解答,希望对你有所帮助。