mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-16 22:23:45 +08:00
del
This commit is contained in:
181
极客时间专栏/geek/网络编程实战/第一模块:基础篇/01 | 追古溯源:TCP|IP和Linux是如何改变世界的?.md
Normal file
181
极客时间专栏/geek/网络编程实战/第一模块:基础篇/01 | 追古溯源:TCP|IP和Linux是如何改变世界的?.md
Normal 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 & 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&T贝尔实验室不断开发的UNIX研究版本,从此引出UNIX分时系统第8版、第9版,终止于1990年的第10版(10.5)。这个版本可以说是操作系统界的少林派。天下武功皆出少林,世上UNIX皆出自贝尔实验室。
|
||||
- 图中最上面所标识的操作系统版本,是加州大学伯克利分校(BSD)研究出的分支,从此引出4.xBSD实现,以及后面的各种BSD版本。这个可以看做是学院派。在历史上,学院派有力地推动了UNIX的发展,包括我们后面会谈到的socket套接字都是出自此派。
|
||||
- 图中最下面的那一个部分,是从AT&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
|
||||
|
||||
SVR4(UNIX System V Release 4)是AT&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
|
||||
|
||||
BSD(Berkeley 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标准了,POSIX(Portable 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操作系统得以发展还有一个非常重要的因素,那就是GNU(GNU’s 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 Gnu’s 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,不过没有Linux,GNU也不可能大发光彩。
|
||||
|
||||
在开源的世界里,也会发生这种争名夺利的事情,我们也不用觉得惊奇。
|
||||
|
||||
## 操作系统对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的发展脉络,欢迎把它分享给你的朋友或者同事。
|
||||
151
极客时间专栏/geek/网络编程实战/第一模块:基础篇/02 | 网络编程模型:认识客户端-服务器网络模型的基本概念.md
Normal file
151
极客时间专栏/geek/网络编程实战/第一模块:基础篇/02 | 网络编程模型:认识客户端-服务器网络模型的基本概念.md
Normal 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/24(255.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.0,192.0.2.0就是这个网络的值。
|
||||
|
||||
子网掩码能接受任意个位,而不单纯是上面讨论的8,16或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”的顺序抵达,而且不会出错。
|
||||
|
||||
这种高质量的通信是如何办到的呢?这就是由TCP(Transmission 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段地址,怎么理解这种描述呢?
|
||||
|
||||
另外,章节里提到了服务端必须侦听在一个众所周知的端口上,这个端口怎么选择,又是如何让客户端知道的呢?
|
||||
|
||||
如果你仔细想过这个问题,欢迎在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,大家一起交流一下。
|
||||
219
极客时间专栏/geek/网络编程实战/第一模块:基础篇/03丨套接字和地址:像电话和电话号码一样理解它们.md
Normal file
219
极客时间专栏/geek/网络编程实战/第一模块:基础篇/03丨套接字和地址:像电话和电话号码一样理解它们.md
Normal 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。我们在<sys/socket.h>头文件中可以清晰地看到,这两个值本身就是一一对应的。
|
||||
|
||||
```
|
||||
/* 各种地址族的宏定义 */
|
||||
#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套接字格式却需要端口号呢?
|
||||
|
||||
我在评论区期待你的思考与见解,如果你觉得这篇文章对你有所帮助,欢迎点击“请朋友读”,把这篇文章分享给你朋友或同事。
|
||||
229
极客时间专栏/geek/网络编程实战/第一模块:基础篇/04 | TCP三次握手:怎么使用套接字格式建立连接?.md
Normal file
229
极客时间专栏/geek/网络编程实战/第一模块:基础篇/04 | TCP三次握手:怎么使用套接字格式建立连接?.md
Normal 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 *) &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 <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <sys/socket.h>
|
||||
#include <netinet/in.h>
|
||||
|
||||
|
||||
int make_socket (uint16_t port)
|
||||
{
|
||||
int sock;
|
||||
struct sockaddr_in name;
|
||||
|
||||
|
||||
/* 创建字节流类型的IPV4 socket. */
|
||||
sock = socket (PF_INET, SOCK_STREAM, 0);
|
||||
if (sock < 0)
|
||||
{
|
||||
perror ("socket");
|
||||
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 *) &name, sizeof (name)) < 0)
|
||||
{
|
||||
perror ("bind");
|
||||
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套接字,因为这就是前面通过bind,listen一系列操作而得到的套接字。函数的返回值有两个部分,第一个部分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三次握手的解读
|
||||
|
||||
我们先看一下最初的过程,服务器端通过socket,bind和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连接的建立。
|
||||
|
||||
- 服务器端通过创建socket,bind,listen完成初始化,通过accept完成连接的建立。
|
||||
- 客户端通过创建socket,connect发起连接建立请求。
|
||||
|
||||
在下一讲里,我们将真正地开始客户端-服务端数据交互的过程。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后给你布置两道思考题。
|
||||
|
||||
第一道是关于阻塞调用的,既然有阻塞调用,就应该有非阻塞调用,那么如何使用非阻塞调用套接字呢?使用的场景又是哪里呢?
|
||||
|
||||
第二道是关于客户端的,客户端发起connect调用之前,可以调用bind函数么?
|
||||
|
||||
欢迎你在评论区与我分享你的答案,如果这篇文章帮助你理解TCP三次握手,也欢迎你点击“请朋友读”,把这篇文章分享给你的朋友或者同事。
|
||||
269
极客时间专栏/geek/网络编程实战/第一模块:基础篇/05 | 使用套接字进行读写:开始交流吧.md
Normal file
269
极客时间专栏/geek/网络编程实战/第一模块:基础篇/05 | 使用套接字进行读写:开始交流吧.md
Normal 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,表示EOF(end-of-file),这在网络中表示对端发送了FIN包,要处理断连的情况**;如果返回值为-1,表示出错。当然,如果是非阻塞I/O,情况会略有不同,在后面的提高篇中我们会重点讲述非阻塞I/O的特点。
|
||||
|
||||
注意这里是最多读取size个字节。如果我们想让应用程序每次都读到size个字节,就需要编写下面的函数,不断地循环读取。
|
||||
|
||||
```
|
||||
/* 从socketfd描述字中读取"size"个字节. */
|
||||
size_t readn(int fd, void *buffer, size_t size) {
|
||||
char *buffer_pointer = buffer;
|
||||
int length = size;
|
||||
|
||||
while (length > 0) {
|
||||
int result = read(fd, buffer_pointer, length);
|
||||
|
||||
if (result < 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 "lib/common.h"
|
||||
|
||||
void read_data(int sockfd) {
|
||||
ssize_t n;
|
||||
char buf[1024];
|
||||
|
||||
int time = 0;
|
||||
for (;;) {
|
||||
fprintf(stdout, "block in read\n");
|
||||
if ((n = readn(sockfd, buf, 1024)) == 0)
|
||||
return;
|
||||
|
||||
time++;
|
||||
fprintf(stdout, "1K read for %d \n", 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(&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 *) &servaddr, sizeof(servaddr));
|
||||
/* listen的backlog为1024 */
|
||||
listen(listenfd, 1024);
|
||||
|
||||
/* 循环处理用户请求 */
|
||||
for (;;) {
|
||||
clilen = sizeof(cliaddr);
|
||||
connfd = accept(listenfd, (struct sockaddr *) &cliaddr, &clilen);
|
||||
read_data(connfd); /* 读取数据 */
|
||||
close(connfd); /* 关闭连接套接字,注意不是监听套接字*/
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
对服务器端程序解释如下:
|
||||
|
||||
- 21-35行先后创建了socket套接字,bind到对应地址和端口,并开始调用listen接口监听;
|
||||
- 38-42行循环等待连接,通过accept获取实际的连接,并开始读取数据;
|
||||
- 8-15行实际每次读取1K数据,之后休眠1秒,用来模拟服务器端处理时延。
|
||||
|
||||
### 客户端发送数据程序
|
||||
|
||||
下面是客户端发送数据的程序:
|
||||
|
||||
```
|
||||
#include "lib/common.h"
|
||||
|
||||
#define MESSAGE_SIZE 102400
|
||||
|
||||
void send_data(int sockfd) {
|
||||
char *query;
|
||||
query = malloc(MESSAGE_SIZE + 1);
|
||||
for (int i = 0; i < 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, "send into buffer %ld \n", n_written);
|
||||
if (n_written <= 0) {
|
||||
error(1, errno, "send failed");
|
||||
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, "usage: tcpclient <IPaddress>");
|
||||
|
||||
sockfd = socket(AF_INET, SOCK_STREAM, 0);
|
||||
|
||||
bzero(&servaddr, sizeof(servaddr));
|
||||
servaddr.sin_family = AF_INET;
|
||||
servaddr.sin_port = htons(12345);
|
||||
inet_pton(AF_INET, argv[1], &servaddr.sin_addr);
|
||||
int connect_rt = connect(sockfd, (struct sockaddr *) &servaddr, sizeof(servaddr));
|
||||
if (connect_rt < 0) {
|
||||
error(1, errno, "connect failed ");
|
||||
}
|
||||
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等异常条件。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后你不妨思考一下,既然缓冲区如此重要,我们可不可以把缓冲区搞得大大的,这样不就可以提高应用程序的吞吐量了么?你可以想一想这个方法可行吗?另外你可以自己总结一下,一段数据流从应用程序发送端,一直到应用程序接收端,总共经过了多少次拷贝?
|
||||
|
||||
欢迎你在评论区与我分享你的答案,如果你理解了套接字读写的过程,也欢迎把这篇文章分享给你的朋友或者同事。
|
||||
366
极客时间专栏/geek/网络编程实战/第一模块:基础篇/06 | 嗨,别忘了UDP这个小兄弟.md
Normal file
366
极客时间专栏/geek/网络编程实战/第一模块:基础篇/06 | 嗨,别忘了UDP这个小兄弟.md
Normal 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 <sys/socket.h>
|
||||
|
||||
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 "lib/common.h"
|
||||
|
||||
static int count;
|
||||
|
||||
static void recvfrom_int(int signo) {
|
||||
printf("\nreceived %d datagrams\n", 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(&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 *) &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 *) &client_addr, &client_len);
|
||||
message[n] = 0;
|
||||
printf("received %d bytes: %s\n", n, message);
|
||||
|
||||
char send_line[MAXLINE];
|
||||
sprintf(send_line, "Hi, %s", message);
|
||||
|
||||
sendto(socket_fd, send_line, strlen(send_line), 0, (struct sockaddr *) &client_addr, client_len);
|
||||
|
||||
count++;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
程序的12~13行,首先创建一个套接字,注意这里的套接字类型是“SOCK_DGRAM”,表示的是UDP数据报。
|
||||
|
||||
15~21行和TCP服务器端类似,绑定数据报套接字到本地的一个端口上。
|
||||
|
||||
27行为该服务器创建了一个信号处理函数,以便在响应“Ctrl+C”退出时,打印出收到的报文总数。
|
||||
|
||||
31~42行是该服务器端的主体,通过调用recvfrom函数获取客户端发送的报文,之后我们对收到的报文进行重新改造,加上“Hi”的前缀,再通过sendto函数发送给客户端对端。
|
||||
|
||||
## UDP客户端例子
|
||||
|
||||
接下来我们再来构建一个对应的UDP客户端。在这个例子中,从标准输入中读取输入的字符串后,发送给服务端,并且把服务端经过处理的报文打印到标准输出上。
|
||||
|
||||
```
|
||||
#include "lib/common.h"
|
||||
|
||||
# define MAXLINE 4096
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (argc != 2) {
|
||||
error(1, 0, "usage: udpclient <IPaddress>");
|
||||
}
|
||||
|
||||
int socket_fd;
|
||||
socket_fd = socket(AF_INET, SOCK_DGRAM, 0);
|
||||
|
||||
struct sockaddr_in server_addr;
|
||||
bzero(&server_addr, sizeof(server_addr));
|
||||
server_addr.sin_family = AF_INET;
|
||||
server_addr.sin_port = htons(SERV_PORT);
|
||||
inet_pton(AF_INET, argv[1], &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("now sending %s\n", send_line);
|
||||
size_t rt = sendto(socket_fd, send_line, strlen(send_line), 0, (struct sockaddr *) &server_addr, server_len);
|
||||
if (rt < 0) {
|
||||
error(1, errno, "send failed ");
|
||||
}
|
||||
printf("send bytes: %zu \n", rt);
|
||||
|
||||
len = 0;
|
||||
n = recvfrom(socket_fd, recv_line, MAXLINE, 0, reply_addr, &len);
|
||||
if (n < 0)
|
||||
error(1, errno, "recvfrom failed");
|
||||
recv_line[n] = 0;
|
||||
fputs(recv_line, stdout);
|
||||
fputs("\n", stdout);
|
||||
}
|
||||
|
||||
exit(0);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
10~11行创建一个类型为“SOCK_DGRAM”的套接字。
|
||||
|
||||
13~17行,初始化目标服务器的地址和端口。
|
||||
|
||||
28~51行为程序主体,从标准输入中读取的字符进行处理后,调用sendto函数发送给目标服务器端,然后再次调用recvfrom函数接收目标服务器发送过来的新报文,并将其打印到标准输出上。
|
||||
|
||||
为了让你更好地理解UDP和TCP之间的差别,我们模拟一下UDP的三种运行场景,你不妨思考一下这三种场景的结果和TCP的到底有什么不同?
|
||||
|
||||
## 场景一:只运行客户端
|
||||
|
||||
如果我们只运行客户端,程序会一直阻塞在recvfrom上。
|
||||
|
||||
```
|
||||
$ ./udpclient 127.0.0.1
|
||||
1
|
||||
now sending g1
|
||||
send bytes: 2
|
||||
<阻塞在这里>
|
||||
|
||||
```
|
||||
|
||||
还记得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这个协议。
|
||||
424
极客时间专栏/geek/网络编程实战/第一模块:基础篇/07 | What? 还有本地套接字?.md
Normal file
424
极客时间专栏/geek/网络编程实战/第一模块:基础篇/07 | What? 还有本地套接字?.md
Normal 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的CRI(Container 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 "lib/common.h"
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (argc != 2) {
|
||||
error(1, 0, "usage: unixstreamserver <local_path>");
|
||||
}
|
||||
|
||||
int listenfd, connfd;
|
||||
socklen_t clilen;
|
||||
struct sockaddr_un cliaddr, servaddr;
|
||||
|
||||
listenfd = socket(AF_LOCAL, SOCK_STREAM, 0);
|
||||
if (listenfd < 0) {
|
||||
error(1, errno, "socket create failed");
|
||||
}
|
||||
|
||||
char *local_path = argv[1];
|
||||
unlink(local_path);
|
||||
bzero(&servaddr, sizeof(servaddr));
|
||||
servaddr.sun_family = AF_LOCAL;
|
||||
strcpy(servaddr.sun_path, local_path);
|
||||
|
||||
if (bind(listenfd, (struct sockaddr *) &servaddr, sizeof(servaddr)) < 0) {
|
||||
error(1, errno, "bind failed");
|
||||
}
|
||||
|
||||
if (listen(listenfd, LISTENQ) < 0) {
|
||||
error(1, errno, "listen failed");
|
||||
}
|
||||
|
||||
clilen = sizeof(cliaddr);
|
||||
if ((connfd = accept(listenfd, (struct sockaddr *) &cliaddr, &clilen)) < 0) {
|
||||
if (errno == EINTR)
|
||||
error(1, errno, "accept failed"); /* back to for() */
|
||||
else
|
||||
error(1, errno, "accept failed");
|
||||
}
|
||||
|
||||
char buf[BUFFER_SIZE];
|
||||
|
||||
while (1) {
|
||||
bzero(buf, sizeof(buf));
|
||||
if (read(connfd, buf, BUFFER_SIZE) == 0) {
|
||||
printf("client quit");
|
||||
break;
|
||||
}
|
||||
printf("Receive: %s", buf);
|
||||
|
||||
char send_line[MAXLINE];
|
||||
sprintf(send_line, "Hi, %s", buf);
|
||||
|
||||
int nbytes = sizeof(send_line);
|
||||
|
||||
if (write(connfd, send_line, nbytes) != nbytes)
|
||||
error(1, errno, "write error");
|
||||
}
|
||||
|
||||
close(listenfd);
|
||||
close(connfd);
|
||||
|
||||
exit(0);
|
||||
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我对这个程序做一个详细的解释:
|
||||
|
||||
- 第12~15行非常关键,**这里创建的套接字类型,注意是AF_LOCAL,并且使用字节流格式**。你现在可以回忆一下,TCP的类型是AF_INET和字节流类型;UDP的类型是AF_INET和数据报类型。在前面的文章中,我们提到AF_UNIX也是可以的,基本上可以认为和AF_LOCAL是等价的。
|
||||
- 第17~21行创建了一个本地地址,这里的本地地址和IPv4、IPv6地址可以对应,数据类型为sockaddr_un,这个数据类型中的sun_family需要填写为AF_LOCAL,最为关键的是需要对sun_path设置一个本地文件路径。我们这里还做了一个unlink操作,以便把存在的文件删除掉,这样可以保持幂等性。
|
||||
- 第23~29行,分别执行bind和listen操作,这样就监听在一个本地文件路径标识的套接字上,这和普通的TCP服务端程序没什么区别。
|
||||
- 第41~56行,使用read和write函数从套接字中按照字节流的方式读取和发送数据。
|
||||
|
||||
我在这里着重强调一下本地文件路径。关于本地文件路径,需要明确一点,它必须是“绝对路径”,这样的话,编写好的程序可以在任何目录里被启动和管理。如果是“相对路径”,为了保持同样的目的,这个程序的启动路径就必须固定,这样一来,对程序的管理反而是一个很大的负担。
|
||||
|
||||
另外还要明确一点,这个本地文件,必须是一个“文件”,不能是一个“目录”。如果文件不存在,后面bind操作时会自动创建这个文件。
|
||||
|
||||
还有一点需要牢记,在Linux下,任何文件操作都有权限的概念,应用程序启动时也有应用属主。如果当前启动程序的用户权限不能创建文件,你猜猜会发生什么呢?这里我先卖个关子,一会演示的时候你就会看到结果。
|
||||
|
||||
下面我们再看一下客户端程序。
|
||||
|
||||
```
|
||||
#include "lib/common.h"
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (argc != 2) {
|
||||
error(1, 0, "usage: unixstreamclient <local_path>");
|
||||
}
|
||||
|
||||
int sockfd;
|
||||
struct sockaddr_un servaddr;
|
||||
|
||||
sockfd = socket(AF_LOCAL, SOCK_STREAM, 0);
|
||||
if (sockfd < 0) {
|
||||
error(1, errno, "create socket failed");
|
||||
}
|
||||
|
||||
bzero(&servaddr, sizeof(servaddr));
|
||||
servaddr.sun_family = AF_LOCAL;
|
||||
strcpy(servaddr.sun_path, argv[1]);
|
||||
|
||||
if (connect(sockfd, (struct sockaddr *) &servaddr, sizeof(servaddr)) < 0) {
|
||||
error(1, errno, "connect failed");
|
||||
}
|
||||
|
||||
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, "write error");
|
||||
|
||||
if (read(sockfd, recv_line, MAXLINE) == 0)
|
||||
error(1, errno, "server terminated prematurely");
|
||||
|
||||
fputs(recv_line, stdout);
|
||||
}
|
||||
|
||||
exit(0);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
下面我带大家理解一下这个客户端程序。
|
||||
|
||||
- 11~14行创建了一个本地套接字,和前面服务器端程序一样,用的也是字节流类型SOCK_STREAM。
|
||||
- 16~18行初始化目标服务器端的地址。我们知道在TCP编程中,使用的是服务器的IP地址和端口作为目标,在本地套接字中则使用文件路径作为目标标识,sun_path这个字段标识的是目标文件路径,所以这里需要对sun_path进行初始化。
|
||||
- 20行和TCP客户端一样,发起对目标套接字的connect调用,不过由于是本地套接字,并不会有三次握手。
|
||||
- 28~38行从标准输入中读取字符串,向服务器端发送,之后将服务器端传输过来的字符打印到标准输出上。
|
||||
|
||||
总体上,我们可以看到,本地字节流套接字和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 "lib/common.h"
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (argc != 2) {
|
||||
error(1, 0, "usage: unixdataserver <local_path>");
|
||||
}
|
||||
|
||||
int socket_fd;
|
||||
socket_fd = socket(AF_LOCAL, SOCK_DGRAM, 0);
|
||||
if (socket_fd < 0) {
|
||||
error(1, errno, "socket create failed");
|
||||
}
|
||||
|
||||
struct sockaddr_un servaddr;
|
||||
char *local_path = argv[1];
|
||||
unlink(local_path);
|
||||
bzero(&servaddr, sizeof(servaddr));
|
||||
servaddr.sun_family = AF_LOCAL;
|
||||
strcpy(servaddr.sun_path, local_path);
|
||||
|
||||
if (bind(socket_fd, (struct sockaddr *) &servaddr, sizeof(servaddr)) < 0) {
|
||||
error(1, errno, "bind failed");
|
||||
}
|
||||
|
||||
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 *) &client_addr, &client_len) == 0) {
|
||||
printf("client quit");
|
||||
break;
|
||||
}
|
||||
printf("Receive: %s \n", buf);
|
||||
|
||||
char send_line[MAXLINE];
|
||||
bzero(send_line, MAXLINE);
|
||||
sprintf(send_line, "Hi, %s", buf);
|
||||
|
||||
size_t nbytes = strlen(send_line);
|
||||
printf("now sending: %s \n", send_line);
|
||||
|
||||
if (sendto(socket_fd, send_line, nbytes, 0, (struct sockadd *) &client_addr, client_len) != nbytes)
|
||||
error(1, errno, "sendto error");
|
||||
}
|
||||
|
||||
close(socket_fd);
|
||||
|
||||
exit(0);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
本地数据报套接字和前面的字节流本地套接字有以下几点不同:
|
||||
|
||||
- 第9行创建的本地套接字,**这里创建的套接字类型,注意是AF_LOCAL**,协议类型为SOCK_DGRAM。
|
||||
- 21~23行bind到本地地址之后,没有再调用listen和accept,回忆一下,这其实和UDP的性质一样。
|
||||
- 28~45行使用recvfrom和sendto来进行数据报的收发,不再是read和send,这其实也和UDP网络程序一致。
|
||||
|
||||
然后我们再看一下客户端的例子:
|
||||
|
||||
```
|
||||
#include "lib/common.h"
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (argc != 2) {
|
||||
error(1, 0, "usage: unixdataclient <local_path>");
|
||||
}
|
||||
|
||||
int sockfd;
|
||||
struct sockaddr_un client_addr, server_addr;
|
||||
|
||||
sockfd = socket(AF_LOCAL, SOCK_DGRAM, 0);
|
||||
if (sockfd < 0) {
|
||||
error(1, errno, "create socket failed");
|
||||
}
|
||||
|
||||
bzero(&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 *) &client_addr, sizeof(client_addr)) < 0) {
|
||||
error(1, errno, "bind failed");
|
||||
}
|
||||
|
||||
bzero(&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("now sending %s \n", send_line);
|
||||
|
||||
if (sendto(sockfd, send_line, nbytes, 0, (struct sockaddr *) &server_addr, sizeof(server_addr)) != nbytes)
|
||||
error(1, errno, "sendto error");
|
||||
|
||||
int n = recvfrom(sockfd, recv_line, MAXLINE, 0, NULL, NULL);
|
||||
recv_line[n] = 0;
|
||||
|
||||
fputs(recv_line, stdout);
|
||||
fputs("\n", stdout);
|
||||
}
|
||||
|
||||
exit(0);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个程序和UDP网络编程的例子基本是一致的,我们可以把它当作是用本地文件替换了IP地址和端口的UDP程序,不过,这里还是有一个非常大的不同的。
|
||||
|
||||
这个不同点就在16~22行。你可以看到16~22行将本地套接字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,路径和其他都是正确的,你觉得会发生什么呢?
|
||||
|
||||
欢迎你在评论区写下你的思考,我会和你一起交流这些问题。如果这篇文章帮你弄懂了本地套接字,不妨把它分享给你的朋友或者同事,一起交流一下它吧!
|
||||
273
极客时间专栏/geek/网络编程实战/第一模块:基础篇/08 | 工欲善其事必先利其器:学会使用各种工具.md
Normal file
273
极客时间专栏/geek/网络编程实战/第一模块:基础篇/08 | 工欲善其事必先利其器:学会使用各种工具.md
Normal 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序列号排序显示的,一并显示的,也包括TTL(time 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形态的TCP,IPV6形态的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]&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 > 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显示的*.*表示的是什么意思呢?
|
||||
|
||||
欢迎你在评论区写下你的思考,我会和你一起交流,也欢迎你把这篇文章分享给你的朋友或者同事,一起讨论下这几个工具。
|
||||
218
极客时间专栏/geek/网络编程实战/第一模块:基础篇/09丨答疑篇:学习网络编程前,需要准备哪些东西?.md
Normal file
218
极客时间专栏/geek/网络编程实战/第一模块:基础篇/09丨答疑篇:学习网络编程前,需要准备哪些东西?.md
Normal 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 && 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 背后的技术叫做LLVM(Low 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 && 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 && 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 中读取报文。
|
||||
|
||||
最后,报文的数据保存在套接字接收缓存中,应用程序从套接字接收缓存中读取数据。
|
||||
|
||||
这就是数据流从应用程序发送端,一直到应用程序接收端的整个过程,你看懂了吗?
|
||||
|
||||
上面的任何一个环节稍有积压,都会对程序性能产生影响。但好消息是,内核和网络设备供应商已经帮我们把一切都打点好了,我们看到和用到的,其实只是冰山上的一角而已。
|
||||
|
||||
这就是基础篇的总结与答疑部分,我先对之前基础篇的内容补充了一些资料,尽可能地为你学习网络编程提供方便,然后针对大家有明显疑惑的问题进行了解答,希望对你有所帮助。
|
||||
Reference in New Issue
Block a user