您的位置:首页 > 理论基础 > 计算机网络

谈一谈网络编程学习经验

2012-10-21 16:58 337 查看
本文谈一谈我在学习网络编程方面的一些个人经验。“网络编程”这个术语的范围很广,本文指用Sockets API开发基于TCP/IP的网络应用程序,具体定义见“网络编程的各种任务角色”一节。

  受限于本人的经历和经验,这篇文章的适应范围是:

  · x86-64 Linux服务端网络编程,直接或间接使用 Sockets API

  · 公司内网。不一定是局域网,但总体位于公司防火墙之内,环境可控

  本文可能不适合:

  · PC客户端网络编程,程序运行在客户的PC上,环境多变且不可控

  ·
Windows网络编程

  · 面向公网的服务程序

  · 高性能网络服务器

  本文分两个部分:

  1. 网络编程的一些胡思乱想,谈谈我对这一领域的认识

  2. 几本必看的书,基本上还是W. Richard Stevents那几本

  另外,本文没有特别说明时均暗指TCP协议,“连接”是“TCP连接”,“服务端”是“TCP服务端”。

  网络编程的一些胡思乱想

  以下胡乱列出我对网络编程的一些想法,前后无关联。

  网络编程是什么?

  网络编程是什么?是熟练使用Sockets API吗?说实话,在实际项目里我只用过两次Sockets API,其他时候都是使用封装好的网络库。

  第一次是2005年在学校做一个羽毛球赛场计分系统:我用C# 编写运行在PC机上的软件,负责比分的显示;再用C# 写了运行在PDA上的计分界面,记分员拿着PDA记录比分;这两部分程序通过
TCP协议相互通信。这其实是个简单的分布式系统,体育馆有不止一片场地,每个场地都有一名拿PDA的记分员,每个场地都有两台显示比分的PC机(显示器是42吋平板电视,放在场地的对角,这样两边看台的观众都能看到比分)。这两台PC机功能不完全一样,一台只负责显示当前比分,另一台还要负责与PDA通信,并更新数据库里的比分信息。此外,还有一台PC机负责周期性地从数据库读出全部7片场地的比分,显示在体育馆墙上的大屏幕上。这台PC上还运行着一个程序,负责生成比分数据的静态页面,通过FTP上传发布到某门户网站的体育频道。系统中还有一个录入赛程(参赛队,运动员,出场顺序等)数据库的程序,运行在数据库服务器上。算下来整个系统有十来个程序,运行在二十多台设备(PC和PDA)上,还要考虑可靠性。将来有机会把这个小系统仔细讲一讲,挺有意思的。

  这是我第一次写实际项目中的网络程序,当时写下来的感觉是像写命令行与用户交互的程序:程序在命令行输出一句提示语,等待客户输入一句话,然后处理客户输入,再输出下一句提示语,如此循环。只不过这里的“客户”不是人,而是另一个程序。在建立好TCP连接之后,双方的程序都是read/write循环(为求简单,我用的是blocking读写),直到有一方断开连接。

  第二次是2010年编写muduo网络库,我再次拿起了Sockets API,写了一个基于Reactor模式的C++ 网络库。写这个库的目的之一就是想让日常的网络编程从Sockets API的琐碎细节中解脱出来,让程序员专注于业务逻辑,把时间用在刀刃上。Muduo 网络库的示例代码包含了几十个网络程序,这些示例程序都没有直接使用Sockets API。

  在此之外,无论是实习还是工作,虽然我写的程序都会通过TCP协议与其他程序打交道,但我没有直接使用过Sockets API。对于TCP网络编程,我认为核心是处理“三个半事件”,见《Muduo 网络编程示例之零:前言》中的“TCP 网络编程本质论”。程序员的主要工作是在事件处理函数中实现业务逻辑,而不是和Sockets API较劲。

  这里还是没有说清楚“网络编程”是什么,请继续阅读后文“网络编程的各种任务角色”。

  学习网络编程有用吗?

  以上说的是比较底层的网络编程,程序代码直接面对从TCP或UDP收到的数据以及构造数据包发出去。在实际工作中,另一种常见 的情况是通过各种 client library 来与服务端打交道,或者在现成的框架中填空来实现server,或者采用更上层的通信方式。比如用libmemcached与memcached打交道,使用libpq来与PostgreSQL 打交道,编写Servlet来响应http请求,使用某种RPC与其他进程通信,等等。这些情况都会发生网络通信,但不一定算作“网络编程”。如果你的工作是前面列举的这些,学习TCP/IP网络编程还有用吗?

  我认为还是有必要学一学,至少在troubleshooting 的时候有用。无论如何,这些library或framework都会调用底层的Sockets API来实现网络功能。当你的程序遇到一个线上问题,如果你熟悉Sockets API,那么从strace不难发现程序卡在哪里,尽管可能你没有直接调用这些Sockets API。另外,熟悉TCP/IP协议、会用tcpdump也大大有助于分析解决线上网络服务问题。

  在什么平台上学习网络编程?

  对于服务端网络编程,我建议在Linux上学习。

  如果在10年前,这个问题的答案或许是FreeBSD,因为FreeBSD根正苗红,在2000年那一次互联网浪潮中扮演了重要角色,是很多公司首选的免费服务器操作系统。2000年那会儿Linux还远未成熟,连epoll都还没有实现。(FreeBSD在2001年发布4.1版,加入了kqueue,从此C10k不是问题。)

  10年后的今天,事情起了变化,Linux成为了市场份额最大的服务器操作系统(http://en.wikipedia.org/wiki/Usage_share_of_operating_systems)。在Linux这种大众系统上学网络编程,遇到什么问题会比较容易解决。因为用的人多,你遇到的问题别人多半也遇到过;同样因为用的人多,如果真的有什么内核bug,很快就会得到修复,至少有work
around的办法。如果用别的系统,可能一个问题发到论坛上半个月都不会有人理。从内核源码的风格看,FreeBSD更干净整洁,注释到位,但是无奈它的市场份额远不如Linux,学习Linux是更好的技术投资。

  可移植性重要吗?

  写网络程序要不要考虑移植性?这取决于项目需要,如果贵公司做的程序要卖给其他公司,而对方可能使用Windows、Linux、FreeBSD、Solaris、AIX、HP-UX等等操作系统,这时候考虑移植性。如果编写公司内部的服务器上用的网络程序,那么大可只关注一个平台,比如Linux。因为编写和维护可移植的网络程序的代价相当高,平台间的差异可能远比想象中大,即便是POSIX系统之间也有不小的差异(比如Linux没有SO_NOSIGPIPE选项),错误的返回码也大不一样。

  我就不打算把muduo往Windows或其他操作系统移植。如果需要编写可移植的网络程序,我宁愿用libevent或者Java Netty这样现成的库,把脏活累活留给别人。

  网络编程的各种任务角色

  计算机网络是个 big topic,涉及很多人物和角色,既有开发人员,也有运维人员。比方说:公司内部两台机器之间 ping 不通,通常由网络运维人员解决,看看是布线有问题还是路由器设置不对;两台机器能ping通,但是程序连不上,经检查是本机防火墙设置有问题,通常由系统管理员解决;两台机器能连上,但是丢包很严重,发现是网卡或者交换机的网口故障,由硬件维修人员解决;两台机器的程序能连上,但是偶尔发过去的请求得不到响应,通常是程序bug,应该由开发人员解决。

  本文主要关心开发人员这一角色。下面简单列出一些我能想到的跟网络打交道的编程任务,其中前三项是面向网络本身,后面几项是在计算机网络之上构建信息系统。

  1. 开发网络设备,编写防火墙、交换机、路由器的固件 firmware

  2. 开发或移植网卡的驱动

  3. 移植或维护TCP/IP协议栈(特别是在嵌入式系统上)

  4. 开发或维护标准的网络协议程序,HTTP、FTP、DNS、SMTP、POP3、NFS

  5. 开发标准网络协议的“附加品”,比如HAProxy、squid、varnish等web load balancer

  6. 开发标准或非标准网络服务的客户端库,比如ZooKeeper客户端库,memcached客户端库

  7. 开发与公司业务直接相关的网络服务程序,比如即时聊天软件的后台服务器,网游服务器,金融交易系统,互联网企业用的分布式海量存储,微博发帖的内部广播通知,等等

  8. 客户端程序中涉及网络的部分,比如邮件客户端中与 POP3、SMTP通信的部分,以及网游的客户端程序中与服务器通信的部分

  本文所指的“网络编程”专指第7项,即在TCP/IP协议之上开发业务软件。

  面向业务的网络编程的特点

  跟开发通用的网络程序不同,开发面向公司业务的专用网络程序有其特点:

  · 业务逻辑比较复杂,而且时常变化

  如果写一个HTTP服务器,在大致实现HTTP /1.1标准之后,程序的主体功能一般不会有太大的变化,程序员会把时间放在性能调优和bug修复上。而开发针对公司业务的专用程序时,功能说明书(spec)很可能不如HTTP/1.1标准那么细致明确。更重要的是,程序是快速演化的。以即时聊天工具的后台服务器为例,可能第一版只支持在线聊天;几个月之后发布第二版,支持离线消息;又过了几个月,第三版支持隐身聊天;随后,第四版支持上传头像;如此等等。这要求程序员能快速响应新的业务需求,公司才能保持竞争力。

  · 不一定需要遵循公认的通信协议标准

  比方说网游服务器就没什么协议标准,反正客户端和服务端都是本公司开发,如果发现目前的协议设计有问题,两边一起改了就是了。

  · 程序结构没有定论

  对于高并发大吞吐的标准网络服务,一般采用单线程事件驱动的方式开发,比如HAProxy、lighttpd等都是这个模式。但是对于专用的业务系统,其业务逻辑比较复杂,占用较多的CPU资源,这种单线程事件驱动方式不见得能发挥现在多核处理器的优势。这留给程序员比较大的自由发挥空间,做好了横扫千军,做烂了一败涂地。

  · 性能评判的标准不同

  如果开发httpd这样的通用服务,必然会和开源的Nginx、lighttpd等高性能服务器比较,程序员要投入相当的精力去优化程序,才能在市场上占有一席之地。而面向业务的专用网络程序不一定有开源的实现以供对比性能,程序员通常更加注重功能的稳定性与开发的便捷性。性能只要一代比一代强即可。

  · 网络编程起到支撑作用,但不处于主导地位

  程序员的主要工作是实现业务逻辑,而不只是实现网络通信协议。这要求程序员深入理解业务。程序的性能瓶颈不一定在网络上,瓶颈有可能是CPU、Disk IO、数据库等等,这时优化网络方面的代码并不能提高整体性能。只有对所在的领域有深入的了解,明白各种因素的权衡(trade-off),才能做出一些有针对性的优化。

  几个术语

  互联网上的很多口水战是由对同一术语的不同理解引起的,比我写的《多线程服务器的适用场合》就曾经人被说是“挂羊头卖狗肉”,因为这篇文章中举的 master例子“根本就算不上是个网络服务器。因为它的瓶颈根本就跟网络无关。”

  · 网络服务器

  “网络服务器”这个术语确实含义模糊,到底指硬件还是软件?到底是服务于网络本身的机器(交换机、路由器、防火墙、NAT),还是利用网络为其他人或程序提供服务的机器(打印服务器、文件服务器、邮件服务器)。每个人根据自己熟悉的领域,可能会有不同的解读。比方说或许有人认为只有支持高并发高吞吐的才算是网络服务器。

  为了避免无谓的争执,我只用“网络服务程序”或者“网络应用程序”这种含义明确的术语。“开发网络服务程序”通常不会造成误解。

  · 客户端?服务端?

  在TCP网络编程里边,客户端和服务端很容易区分,主动发起连接的是客户端,被动接受连接的是服务端。当然,这个“客户端”本身也可能是个后台服务程序,HTTP Proxy对HTTP Server来说就是个客户端。

  · 客户端编程?服务端编程?

  但是“服务端编程”和“客户端编程”就不那么好区分。比如 Web crawler,它会主动发起大量连接,扮演的是HTTP客户端的角色,但似乎应该归入“服务端编程”。又比如写一个 HTTP proxy,它既会扮演服务端——被动接受 web browser 发起的连接,也会扮演客户端——主动向 HTTP server 发起连接,它究竟算服务端还是客户端?我猜大多数人会把它归入服务端编程。

  那么究竟如何定义“服务端编程”?

  服务端编程需要处理大量并发连接?也许是,也许不是。比如云风在一篇介绍网游服务器的博客http://blog.codingnow.com/2006/04/iocp_kqueue_epoll.html中就谈到,网游中用到的“连接服务器”需要处理大量连接,而“逻辑服务器”只有一个外部连接。那么开发这种网游“逻辑服务器”算服务端编程还是客户端编程呢?

  我认为,“服务端网络编程”指的是编写没有用户界面的长期运行的网络程序,程序默默地运行在一台服务器上,通过网络与其他程序打交道,而不必和人打交道。与之对应的是客户端网络程序,要么是短时间运行,比如wget;要么是有用户界面(无论是字符界面还是图形界面)。本文主要谈服务端网络编程。

  7x24重要吗?内存碎片可怕吗?

  一谈到服务端网络编程,有人立刻会提出7x24运行的要求。对于某些网络设备而言,这是合理的需求,比如交换机、路由器。对于开发商业系统,我认为要求程序7x24运行通常是系统设计上考虑不周。具体见《分布式系统的工程化开发方法》第20页起。重要的不是7x24,而是在程序不必做到7x24的情况下也能达到足够高的可用性。一个考虑周到的系统应该允许每个进程都能随时重启,这样才能在廉价的服务器硬件上做到高可用性。

  既然不要求7x24,那么也不必害怕内存碎片,理由如下:

  · 64-bit系统的地址空间足够大,不会出现没有足够的连续空间这种情况。

  · 现在的内存分配器(malloc及其第三方实现)今非昔比,除了memcached这种纯以内存为卖点的程序需要自己设计分配器之外,其他网络程序大可使用系统自带的malloc或者某个第三方实现。

  · Linux Kernel也大量用到了动态内存分配。既然操作系统内核都不怕动态分配内存造成碎片,应用程序为什么要害怕?

  · 内存碎片如何度量?有没有什么工具能为当前进程的内存碎片状况评个分?如果不能比较两种方案的内存碎片程度,谈何优化?

  有人为了避免内存碎片,不使用STL容器,也不敢new/delete,这算是premature optimization还是因噎废食呢?

  协议设计是网络编程的核心

  对于专用的业务系统,协议设计是核心任务,决定了系统的开发难度与可靠性,但是这个领域还没有形成大家公认的设计流程。

  系统中哪个程序发起连接,哪个程序接受连接?如果写标准的网络服务,那么这不是问题,按RFC来就行了。自己设计业务系统,有没有章法可循?以网游为例,到底是连接服务器主动连接逻辑服务器,还是逻辑服务器主动连接“连接服务器”?似乎没有定论,两种做法都行。一般可以按照“依赖->被依赖”的关系来设计发起连接的方向。

  比新建连接难的是关闭连接。在传统的网络服务中(特别是短连接服务),不少是服务端主动关闭连接,比如daytime、HTTP/1.0。也有少部分是客户端主动关闭连接,通常是些长连接服务,比如 echo、chargen等。我们自己的业务系统该如何设计连接关闭协议呢?

  服务端主动关闭连接的缺点之一是会多占用服务器资源。服务端主动关闭连接之后会进入TIME_WAIT状态,在一段时间之内hold住一些内核资源。如果并发访问量很高,这会影响服务端的处理能力。这似乎暗示我们应该把协议设计为客户端主动关闭,让TIME_WAIT状态分散到多台客户机器上,化整为零。

  这又有另外的问题:客户端赖着不走怎么办?会不会造成拒绝服务攻击?或许有一个二者结合的方案:客户端在收到响应之后就应该主动关闭,这样把 TIME_WAIT 留在客户端。服务端有一个定时器,如果客户端若干秒钟之内没有主动断开,就踢掉它。这样善意的客户端会把TIME_WAIT留给自己,buggy的客户端会把 TIME_WAIT留给服务端。或者干脆使用长连接协议,这样避免频繁创建销毁连接。

  比连接的建立与断开更重要的是设计消息协议。消息格式很好办,XML、JSON、Protobuf都是很好的选择;难的是消息内容。一个消息应该包含哪些内容?多个程序相互通信如何避免race condition(见《分布式系统的工程化开发方法》p.16的例子)?系统的全局状态该如何跃迁?可惜这方面可供参考的例子不多,也没有太多通用的指导原则,我知道的只有30年前提出的end-to-end principle和happens-before relationship。只能从实践中慢慢积累了。

  网络编程的三个层次

  侯捷先生在《漫談程序員與編程》中讲到 STL 运用的三个档次:“會用STL,是一種檔次。對STL原理有所了解,又是一個檔次。追蹤過STL源碼,又是一個檔次。第三種檔次的人用起 STL 來,虎虎生風之勢絕非第一檔次的人能夠望其項背。”

  我认为网络编程也可以分为三个层次:

  1. 读过教程和文档

  2. 熟悉本系统TCP/IP协议栈的脾气

  3. 自己写过一个简单的TCP/IP stack

  第一个层次是基本要求,读过《Unix网络编程》这样的编程教材,读过《TCP/IP详解》基本理解TCP/IP协议,读过本系统的manpage。这个层次可以编写一些基本的网络程序,完成常见的任务。但网络编程不是照猫画虎这么简单,若是按照manpage的功能描述就能编写产品级的网络程序,那人生就太幸福了。

  第二个层次,熟悉本系统的TCP/IP协议栈参数设置与优化是开发高性能网络程序的必备条件。摸透协议栈的脾气还能解决工作中遇到的比较复杂的网络问题。拿Linux的TCP/IP协议栈来说:

  · 有可能出现自连接(见《学之者生,用之者死——ACE历史与简评》举的三个硬伤),程序应该有所准备。

  · Linux的内核会有bug,比如某种TCP拥塞控制算法曾经出现TCP window clamping(窗口箝位)bug,导致吞吐量暴跌,可以选用其他拥塞控制算法来绕开(work around)这个问题。

  这些阴暗角落在manpage里没有描述,要通过其他渠道了解。

  编写可靠的网络程序的关键是熟悉各种场景下的error code(文件描述符用完了如何?本地ephemeral port暂时用完,不能发起新连接怎么办?服务端新建并发连接太快,backlog用完了,客户端connect会返回什么错误?),有的在manpage里有描述,有的要通过实践或阅读源码获得。

  第三个层次,通过自己写一个简单的TCP/IP协议栈,能大大加深对TCP/IP的理解,更能明白TCP为什么要这么设计,有哪些因素制约,每一步操作的代价是什么,写起网络程序来更是成竹在胸。

  其实实现TCP/IP只需要操作系统提供三个接口函数:一个函数,两个回调函数。分别是:send_packet()、on_receive_packet()、on_timer()。多年前有一篇文章《使用libnet与libpcap构造TCP/IP协议软件》介绍了在用户态实现TCP/IP的方法。lwIP也是很好的借鉴对象。

  如果有时间,我打算自己写一个Mini/Tiny/Toy/Trivial/Yet-Another TCP/IP。我准备换一个思路,用TUN/TAP设备在用户态实现一个能与本机点对点通信的TCP/IP协议栈,这样那三个接口函数就表现为我最熟悉的文件读写。在用户态实现的好处是便于调试,协议栈做成静态库,与应用程序链接到一起(库的接口不必是标准的Sockets API)。做完这一版,还可以继续发挥,用FTDI的USB-SPI接口芯片连接ENC28J60适配器,做一个真正独立于操作系统的TCP/IP stack。如果只实现最基本的IP、ICMP
Echo、TCP的话,代码应能控制在3000行以内;也可以实现UDP,如果应用程序需要用到DNS的话。

  最主要的三个例子

  我认为TCP网络编程有三个例子最值得学习研究,分别是echo、chat、proxy,都是长连接协议。

  Echo的作用:熟悉服务端被动接受新连接、收发数据、被动处理连接断开。每个连接是独立服务的,连接之间没有关联。在消息内容方面Echo有一些变种:比如做成一问一答的方式,收到的请求和发送响应的内容不一样,这时候要考虑打包与拆包格式的设计,进一步还可以写简单的HTTP服务。

  Chat的作用:连接之间的数据有交流,从a收到的数据要发给b。这样对连接管理提出的更高的要求:如何用一个程序同时处理多个连接?fork() per connection似乎是不行的。如何防止串话?b有可能随时断开连接,而新建立的连接c可能恰好复用了b的文件描述符,那么a会不会错误地把消息发给c?

  Proxy的作用:连接的管理更加复杂:既要被动接受连接,也要主动发起连接,既要主动关闭连接,也要被动关闭连接。还要考虑两边速度不匹配,见《Muduo 网络编程示例之十:socks4a 代理服务器》。

  这三个例子功能简单,突出了TCP网络编程中的重点问题,挨着做一遍基本就能达到层次一的要求。

  TCP的可靠性有多高?

  TCP是“面向连接的、可靠的、字节流传输协议”,这里的“可靠”究竟是什么意思?《Effective TCP/IP Programming》第9条说:Realize That TCP Is a Reliable Protocol, Not an Infallible Protocol,那么TCP在哪种情况下会出错?这里说的“出错”指的是收到的数据与发送的数据不一致,而不是数据不可达。

  我在《一种自动反射消息类型的 Google Protobuf 网络传输方案》中设计了带check sum的消息格式,很多人表示不理解,认为是多余的。IP header里边有check sum,TCP header也有check sum,链路层以太网还有CRC32校验,那么为什么还需要在应用层做校验?什么情况下TCP传送的数据会出错?

  IP header和TCP header的check sum不包括数据payload,以太网的CRC32只能保证同一个网段上的通信不会出错(两台机器的网线插到同一个交换机上,这时候以太网的CRC是有用的)。但是,如果两台机器之间经过了多级路由器呢?





  上图中Client向Server发了一个TCP segment,这个segment先被封装成一个IP packet,再被封装成ethernet frame,发送到路由器(图中消息a)。Router收到ethernet frame (b),转发到另一个网段(c),最后Server收到d,通知应用程序。Ethernet CRC能保证a和b相同,c和d相同;TCP/IP header check sum能保证a和d的payload长度和先后顺序不变,但是没什么能保证收发payload的内容一样。更重要的是,TCP本身发现不了这个错误,也不会重传(再说重传了也没用)。

  路由器可能出现硬件故障,比方说它的内存故障(或偶然错误)导致收发IP报文出现单bit的反转,这个反转如果发生在payload区,那么无法用链路层、网络层、传输层的check sum查出来,只能通过应用层的check sum来检测。这个现象在开发的时候不会遇到,因为开发用的几台机器很可能都连到同一个交换机,ethernet CRC能防止错误。开发和测试的时候数据量不大,错误很难发生。之后大规模部署到生产环境,网络环境复杂,这时候出个错就让人措手不及。有一篇论文《When
the CRC and TCP checksum disagree》分析了这个问题。

  这个情况真的会发生吗?会的,Amazon S3 在2008年7月就遇到过,单bit反转导致了一次严重线上事故,所以他们吸取教训加了 check sum。

  另外一个例证:下载大文件的时候一般都会附上MD5,这除了有安全方面的考虑(防止篡改),也说明应用层应该自己设法校验数据的正确性。这是end-to-end principle的一个例证。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: