天下大势,分久必合,合久必分。
——《三国演义》(罗贯中)
在1.1.2节,我们简要介绍了互联网的诞生。本节将从协议和程序员的角度出发,说明如何进行网络编程实践。
网络上传递的二进制数据是分层的。经典的模型有TCP/IP的四层网络协议、OSI的七层网络协议 。
网络协议分层的结果,是在应用层数据包前面,附加若干协议头,如图1-16所示,括号内数字为该部分通常所占的字节数。
图1-16 网络协议分层
MAC地址与MAC头
互联网由主机(Host)、路由器(Router)和网关(Gateway)等设备连接而成。
计算机和手机终端通过自带的网卡连接到某个路由器,从而获得与互联网的物理连接。
有线网卡使用有线连接路由器,最常见的物理层协议为802.3以太网协议。
无线网卡使用无线电信号(Wi-Fi),最常见的物理层协议为802.11协议族。
为了区分每个物理层的网卡设备,在链路层,为每个网卡都定义了一个MAC(Media Access Control)地址,又叫网卡的 物理地址 。
MAC地址是一个6字节的数值,前3字节定义了厂商,后3字节由厂商定义设备。
一个典型的MAC地址为00-01-6C-06-A6-29。
任意网络包的最前面通常都包含一个MAC头,这是一个14字节的数据。
MAC头定义了一对物理连接主机,同时定义了上层协议的类型,如表1-6所示。
表1-6 MAC头的格式
IP地址与IP头
MAC头标识了相邻主机之间的传输,我们需要解决的是互联网上任意两台主机之间的传输。如何定义互联网上任意的机器地址呢?这就是IP(Internet Protocol)层要解决的问题。我们为每个网络中的主机都分配一个IP地址。
早期的IP地址是4字节的,通常称为IPv4。一个典型的IPv4地址为:10.83.241.172,每个部分为0~255的数字。
根据适用网络的大小,IP地址可分为A~E共5类,比如C类地址可有254台主机。
IP头通常是一个20字节的数据块,IP头的格式如表1-7所示。
表1-7 IP头的格式
在局域网中,已知对方的IP地址,想知道其MAC地址,需要查询本机上的ARP(Ad dress Resolution Protocol)表。
在Windows上使用命令arp-a;在macOS/UNIX上使用命令arp-n。
若ARP表为空,则该工具会发送ARP广播收集各个主机的MAC地址。
如果已知MAC地址,想要反查其IP地址,也可以通过此表实现。
若要查看本机的IP地址,在Windows上使用ipconfig命令,在macOS/UNIX上使用ifconfig命令。这个命令在macOS下也可以看到MTU(Maxinum Trasnsmission Unit)值,当MTU值为1500的时候,IP层是不需要分段(Fragment)的。
IP层有两个有用的工具,都基于ICMP(Internet Control Message Protocol)包实现。
·想要看到一个IP网络是否通畅,可以使用ping命令。
·想要看到一个IP路径上会经过哪几个路由器,可以使用tracert命令。它实际上是从步长1开始,连续发TTL=1,2,3,…的ICMP包,让沿路节点返回TTL过期的回应,从而得到对应的IP地址。
随着IPv4地址的枯竭,IPv6技术应运而生。每个IPv6地址都使用128位16字节表示。一个典型的IPv6地址为ff1:ce00:1285:869:2052:be84:e6e0。
UDP和TCP
IP层解决了任意两台主机之间的连通性问题,然而两台主机之间应该会有很多要通信的内容类型,有的涉及网络诊断,有的涉及应用数据。在传输层,我们通过定义2字节的 端口 (Port)来区分服务,端口号的范围是0~65535。
最简单的传输层是UDP(User Datagram Protocol),它只有8字节,如表1-8所示。
表1-8 UDP头的格式
UDP只保证包能发出,并不确保对方一定能收到。发出去的UDP包,只会层层路由到目的地址,不会有任何回应。
为了保证应用层的可靠传输,人们设计了TCP(Transmission Control Protocol)。TCP的协议头至少有20字节,如表1-9所示。
表1-9 TCP头的格式
为了确保对方一定能收到包,TCP做了以下3件事。
(1)每个发出去的包都有一个编号SEQ,对方收到后会回应ACK=SEQ+1的回包。
(2)如果长时间(具体见下文)没有收到回应包,则TCP会选择 超时重发 。
(3)如果超时重发还是没收到回应包,则TCP会将等待时长翻倍,并再次重发。
TCP将一个包从发出到收到回应的时间,定义为 往返时间 (Round Trip Time,RTT)。
每个TCP连接都会动态维护一个RTT数值。每新收到一个回包,就用式(1.1)更新。
其中,0≤ α <1,且 α 越小,对延迟变化越灵敏,通常取 α =0.125。
对于超时重发时间(Retransmission Time Out,RTO),有RTO= β ×RTT,通常取 β =2。
例如,如果RTT=0.5s,则超时重发时间点通常为2×RTT=1s,之后每次翻倍,为2s,4s,8s,…这里采用指数级退避算法,是为了保证网络不被拥堵。
TCP使用三次握手,提前为通信双方保留必要的资源。包类型为SYN包、SYN+ACK包和ACK包。在断开连接时,双方分别发送FIN包,响应FIN+ACK包,共四次挥手。
TCP使用了滑动窗口,来对每个发出去待确认的包进行加窗式队列管理,这增大了同时等待回包的并发数。
为了提高数据包的负载效率,TCP会使用Nagle算法,等若干包积累一段时间后再一起发送,可以使用TCP_NODELAY选项关闭该特性。
加密与解密
网络中未加密的数据都是明文传输的,想要保密就必须加密。常见的加密算法分为两类: 对称加密 和 非对称加密 ,如图1-17所示。
图1-17 加密与解密
所谓对称加密,就是加密用的密钥(Key)和解密用的密钥是同一个。 密钥 通常是一个很短的字节码,比如16字节或32字节,它是加密算法得到不同加密结果的关键。
非对称加密则使用不同的公钥和私钥。公钥是公开的,人人都可以获取使用;私钥只有自己知道。
由于非对称加密比较慢,在SSL或TLS之类的加密方法中,通常使用非对称加密来传递对称加密的密钥,再进行对称加密传输。
常见的非对称加密算法有RSA、DSA、ECDSA等。
常见的对称加密算法有DES、3DES、AES等。
另外有一类消息摘要算法,它们将输入的任意长度字节转换为固定长度字节。如MD5固定输出16字节,SHA-1固定输出20字节。这种转换是不可逆的,我们常用这类算法来提取一段字节的特征,用作密钥生成、内容校验等。
接下来,我们将介绍如何进行UDP和TCP编程。
在现代操作系统上,我们使用Socket API进行网络的连接与数据的发送。
使用socket()和closesocket()/close()函数,可以进行Socket的创建和销毁。
UDP编程
对UDP客户端,只要创建好UDP Socket,然后用sendto()和recvfrom()即可。
对于UDP服务器,需要先bind()一个端口,这样就可以接收来自任意IP的UDP请求。
由于recvfrom()默认是阻塞的,服务器通常在一个while循环中使用它来等待请求到来并进行处理。客户端则可以在sendto()之后使用它来等待回应。
TCP编程
TCP由于要建立连接,客户端多了connect()和shutdown(),服务器多了listen()和accept()。
一旦建立连接,则TCP使用send()和recv()进行数据的发送和接收。
recv()默认也是阻塞的,可以用来等待请求或等待回包。
多线程编程
上述API是Socket编程中最基础的模型,被称为 阻塞模型 。阻塞模型可以使程序的状态非常清晰,但同时也会让调用线程无法响应。
因此,我们需要引入 线程 (Thread)来管理所有的阻塞操作。
C++标准库为我们封装了std::thread类,可以比较方便地创建线程。
多线程编程的常见问题是多个线程之间的互斥与同步。
所谓 互斥 ,就是一个资源同一时间只允许一个线程对其进行访问修改。因为不同的线程代码访问同一个内存地址时,在汇编级别会有寄存器缓存问题,特定时序下会导致修改被覆盖。常见的互斥工具有原子变量(std::atomic)、临界区(CriticalSection)、自旋锁(SpinLock)、互斥量(mutex)、读写锁(RWLock)等。
所谓 同步 ,就是一个线程等待其他线程执行完,或者满足特定的条件后,再开始运行。常见的工具有事件(Event)、闭锁(CountDownLatch)、条件变量(ConditionVariable)、信号量(Semaphore)等。自旋锁(SpinLock)也可以用作短暂的线程间的同步等待。
由于工具各有千秋,有时候也会面临选择困难。
使用 消息队列 (Message Queue)将任务串行化,也是较好的解决方案。
图1-18实现了一个最简单的消息队列。它使用了一个线程,运行在一个循环上,当消息队列里有消息时,它就从中取出消息,然后处理它。
图1-18 一个最简单的消息队列
网络编程模型
网络编程是一个非常大的话题,下面是概要性的介绍。
Windows下常见网络编程的I/O模型有6种,如表1-10所示。
表1-10 常见Windows网络编程的6种I/O模型
UNIX/Linux下的I/O模型有4种,如表1-11所示。
表1-11 UNIX/Linux网络编程模型
Windows的后4种和UNIX的后2种,都是本平台为了提升性能而定制的,并不具备跨平台的特性。它们在各自的平台上都有不错的性能表现。一些开源库(如libuv)对它们进行了跨平台的代码封装。
在后面,我们将使用select模型开发一个最简单的跨平台TCP通信程序。
本节将实现一个最简单的异步TCP服务器,用它来完成文本数据的收发。
TCP服务器总体的界面如图1-19所示。
图1-19 TCP服务器总体的界面
为了使代码跨平台,我们采用了select模型,虽然性能不如I/O完成端口模型或epoll模型,但方便演示,且一般场景完全够用。
接口设计
我们期望用一个封装类DTCPServer来实现TCP服务器的基础能力,其示例代码如下。
当TCPServer成功启动后,它就被动地等在那里,等待连接接入和请求数据的到来。
所有发生的事件,都通过回调方法通知到主线程。
状态规划
我们注意到,服务器的接口与回调都是异步的。要实现这样一个异步系统,首先规划一下系统内部的 状态 ,以及状态之间的转换。
状态需要用原子变量(atomic)表示,这样可以保证多个线程访问的原子性。
使用状态来设计异步系统,有如下好处。
(1)可以防止异步接口的调用重入,例如Start()的重复调用。
(2)可以对每个函数在每种状态下的行为都有精确的定义。
(3)有助于线程的清晰设计。
经过分析,我们认为服务器至少要有3种状态。
TCP服务器的状态转换如图1-20所示。
图1-20 TCP服务器的状态转换
线程模型
当我们规划线程的时候,可以想象成在规划一家公司的人员安排。每个线程都好比一个工人,每个工人都有自己的工作流程、运转逻辑和处事方法。除UI主线程外,我们的服务器只用了两个线程,如图1-21所示。
·主监听线程:负责服务器的初始化,然后一直在select()函数上循环等待事件的到来。一旦有数据到来,监听线程要负责接收数据,进行数据分析处理,然后进行回包。回包这件事,可以交给下面的线程来处理。
·回应线程:这是一个消息队列,监听线程需要回的包,交由这个队列来负责分发。这样可以提高监听线程的运转效率,不至于阻塞在回包的网络操作上。
图1-21 TCP服务器的线程
创建线程容易,销毁却很难。当调用Stop()的时候,监听线程可能正在接入新的连接,回应队列里可能还有未发送的回包。
对于前者,我们的选择是:在监听的主Socket上调用close(),这会让所有阻塞的操作(如select()、recv())立即返回错误,监听循环退出。为了确保循环能退出,我们在调用线程的末尾使用一个同步变量,至多等待200 ms。
对于后者,我们的选择是:队列不再接收新的消息,清空队列中所有未发送的消息,等待当前发送函数返回 后,自然退出消息队列。
另外,对于服务器所维护的所有Socket连接,我们也要进行关闭处理。
连接维护
在服务器,使用一个动态数组(vector)来维护来自客户端的连接Socket。
当有新连接接入时,监听线程会分配一个新ID,并同新Socket一起加入数组中。
当有连接退出/断开时,监听线程将对应的项目从数组中移除。
基于这个数组,可以开发广播功能,即对数组中每个Socket都进行数据发送。
我们也可以开发服务器主动下推消息的功能,即找到对应的Socket,进行数据发送。
协议处理
当我们收到来自某个Socket的请求数据时,需要进行数据的解包与处理,Hello Server数据格式如表1-12所示。
表1-12 Hello Server数据格式
服务器可以处理5种命令请求(Client→Server),如表1-13所示。
表1-13 Hello Server命令格式-CS
括号内的数字表示该字段的字节数。名字长度使用2字节,文本长度使用4字节。为了能跨平台支持中文,名字和文本均使用UTF-8编码进行存储。
服务器可以向客户端发出4种命令(Server→Client),如表1-14所示。
表1-14 Hello Server命令格式-SC
当所有上述设计都已确定时,剩下的就是编码细节,大家可以参看示例工程。
上一节,我们设计了一个异步的TCPServer,现在我们来看TCP客户端的情况。
TCP客户端界面如图1-22所示。
图1-22 TCP客户端界面
程序运行后,单击“Connect”按钮连接到填写的IP地址和端口上 。连接以后,可以进行改名、单发、群发等操作。左侧的聊天框会展示聊天记录,右侧的列表框展示了当前所有连上服务器的用户。当使用单聊时,需要先在列表中选中对应的人名。
接口设计
同样,我们希望在今后能复用TCPClient的能力,因此也设计了一套异步接口。
客户端一旦连接,便可使用Send接口异步发送数据,执行结果通过DTCPClientSink进行回调。当服务器返回数据时,通过OnRecvBuf进行通知。
状态规划
由于客户端多了连接的过程,因此共有4种状态。
表1-15是TCP客户端状态转移矩阵,当状态处于顶行对应列时,调用左侧函数,系统应该转为表格中对应状态的值。-表示不允许函数调用与状态转移。
表1-15 TCP客户端状态转移矩阵
线程模型
对客户端而言,connect()、send()、recv()操作默认都是阻塞的。简化起见,我们对每个操作都使用一个单独的线程,如图1-23所示。
图1-23 TCP客户端线程模型
发送队列比较独立,可以在初始化的时候就创建好。连接线程在需要连接的时候才创建,连接完毕即退出。一旦连上,就启动一个接收线程的消息循环RecvLoop()。
实现细节
对任何系统而言,设计只是第一步。在实际编码时,仍需要关注很多细节问题。
超时处理: 如果我们在客户端连接时,忘了启动服务器,则connect()函数过一段时间后,会返回一个错误。在Windows上,用WSAGetLastError()可以获得对应的错误码,用FormatMessage()可以将错误码转为对应的错误原因描述。
这个超时时间通常是系统默认的(如5s),如果要自定义这个时间,则将Socket设置为非阻塞的模式,并使用select()方法的第5个参数来指定一个等待时间。
对于同步的接口,用户可以配置一个超时时间,通常是非常友好和必要的。
回调管理: 在示例工程中,我们将对话框对象作为回调对象,直接传给TCPClient。
这在示例工程中不会有问题,但如果回调对象的生命周期不在控制范围内,则很可能会产生问题。比如,外部对象已经销毁了,但TCPClient内部仍在使用指针进行回调。或者内部正在调用回调函数时,外部对象被销毁了。
一个比较好的做法是,外部将回调对象使用std::shared_ptr<T>来管理,而在组件内部维护一个对应std::weak_ptr<T>的弱引用。在每次回调前,都使用lock()将其提升成强引用,再进行调用。若提升失败,则不再调用。
智能指针的原理是使用 引用计数 ,这十分有利于同步对象在内外部的生命周期。
跨线程传递: 当使用消息队列发送Buffer时,我们使用了Buffer的Detach()函数。该函数会让Buffer内部的指针脱离对象生命周期的管理,即Buffer的析构函数不会使其释放内存。
之所以要这样,是因为我们在进行对象内存的跨栈传递时,不希望其自动析构。但我们要记得在队列线程中使用Attach()方法将Buffer的指针接管回来,否则会造成内存泄漏。并且,如果要销毁消息队列,也要清理掉队列中所有待释放的裸指针。
优雅地关闭: 如果在客户端连接状态下,用户直接调用了销毁,此时来不及四次挥手,Socket就被强行关闭了。对于示例工程来说,这也不会有什么问题。但通常情况下,我们希望用户能从容优雅地关闭,即主动调用disconnect()方法。
disconnect()方法会调用shutdown()进行挥手,并至多等待200ms。如果服务器在此期间仍然没有回应,我们才会关闭Socket。这里的等待时间,能让通信双方做更多准备。
经验原则
我们再提炼一下多线程编程的实践经验。
(1)同步的接口一定要设计超时。
(2)异步的接口一定要规划状态。
(3)实现调用时,先判状态,再分情况,尽量串行。
(4)实现销毁时,先清排队,再去阻塞,最后稍待。
(5)单个线程负责单一的行为,有明确的输入输出、数据边界。
(6)线程间共享的数据需要加锁。
(7)线程间传递的数据别忘了释放。
本节我们总结了计算机网络中的基础概念,回顾了从物理层到应用层的特性,着重对比了UDP和TCP这两种最基本的网络协议。
我们设计编写了HelloRTC示例,使用TCP从一端发送信息到服务器,并中转到另一端,实现了一个小型的跨平台聊天室。我们用简洁可复用的代码,实践了人们在互联网上进行通信探索的经验沉淀。
然而,文本的发送仅仅只是开始,我们需要的是将音视频也作为信息传递到远方。
下一章即将开始我们的实时音视频之旅。
1. Internetworking with TCP/IP,Vol 1 ~3,Doglas E.Comer著,是学习网络协议非常好的著名教材。
2. TCP/IP Illustrated,Vol 1 ~3,W.Richard Stevens著,也是网络协议方面的经典。
3.Windows下权威著作是《Windows网络编程》(2002年)。
4.《Linux多线程服务器编程》(2013年),陈硕著,讲述了多线程编程的各种方法。其中提到,几乎全部的线程问题都可以通过mutex和CountDownLauch完美解决。
5.Windows下还有一本侯捷翻译的《Win32多线程程序设计》(2002年)。
6.ARP的细节,参看RFC 826。
7.IP的细节,参看RFC 791。
8.常用服务保留的端口号,可以在Linux下使用cat/etc/services进行查看。
9.UDP和TCP的细节,可以查看RFC 768和RFC 793,其作者都是Postel。
1.[3小时](连接超时)为TCPClient实现一个设置超时的接口。
2.[2人天](TCP滑动窗口)编写一个TCP滑动窗口的模拟程序,通过设置不同的丢包概率,模拟发送端和接收端各自窗口的情况。
3.[2人天](Android Socket)编写Android平台的代码,使之可以通过Socket连接到Windows Server。
4.[3人天](epoll Server)使用epoll模型,实现一个Linux下对应的Server。
5.[3人天](kqueue Server)macOS下的高效I/O模型为kqueue,请实现对应的Server。
6.[5人天](Protobuf组包)Protobuf是Google设计的高效传输数据格式,了解它,并改写本节的发送包格式为PB。
7.[30分钟](域名解析)如何获取一个域名对应的IP地址?
8.[2人天](P2P改造)尝试修改TCPServer,让其能传递对方IP地址和端口,从而能直接使用UDP进行点对点的消息发送。
9.[2小时](DevRTT)传统的RTO计算方法,当RTT变化较大时,会引起不必要的重传。了解使用RTT偏差优化计算RTO的方法。
10.[5人天](RTM SDK)实现一个消息SDK,能支持简单的登录与退出、点对点发消息、房间内广播消息等基本功能。