标签 TCP 下的文章

回顾TCP协议那些事儿

我不是计算机科班出身。记得大学的时候旁听计算机系的网络课,当时计算机系使用教材是"计算机网络–自顶向下方法与Internet特色"的影印版,这本教材与众不同的一个地方就是作者JAMES F.KUROSEKEITH W.ROSS采用了'自顶向下'的编排思路,先从应用层开始,最后讲到物理层。而且这本书在语言上形象生动,通俗易懂。只怪我当初没有一心一意听讲,到现在存在我的脑子中的基本概念居多,深刻理解甚少。以致于工作后遇到此类的问题,只能恶补。这不,在12月1日凌晨全国统一短信类服务接入代码的调整工作中,我就遇到了此类问题,不得不再次抱起W.Richard Stevens的'TCP详解卷一'啃了啃,回顾一下TCP协议那些事儿。

做应用层网络程序开发的,手头上都有一把利器:抓包工具,更专业的名词就是协议分析工具,常用的且功能强大的协议分析工具有:TCPDUMP(Windows平台上的叫Windump)、Ethereal等。工作中常常会遇到因应用层程序在协议字段发送和接收解析上不一致而出现'纠纷'问题,这时我们一般采用的在TCP层用协议分析工具抓取该层原始数据包作为'对峙'的证据;还有的就是在客户端与服务器端链接问题上的一些现象也需要到TCP层去分析原因,这就需要对TCP层的基本工作原理有一个清晰的认识。

首先我们要明确:TCP头部中设置的一系列域都是为了能达到分割、重传、查重、重组、流控、全双工的协议功能而设置的,这里比较重要的字段就是序列号和确认号。由于要达到重传、查重、重组、全双工这些目的,TCP层需要通过序列号和确认号来保证。序列号用来标识发送端传送数据包的顺序,并且指导接收端对多数据包进行顺序重组;发送端传送一个数据包后,它会把这个数据包放入重发队列中,同时启动计时器,如果收到了关于这个包的确认信息,便将此数据包从队列中删除;如果在计时器超时的时候仍然没有收到确认信息,则需要重新发送该数据包。

TCP层以"三次握手"建立链接而"闻名于世",三次握手的目的:建立链接,为后续的数据流传输奠基,因为TCP是双工的,因此在握手过程需告知彼此数据包发送的起始序列号。

Client –> 置SYN标志 序列号 = J,确认号 = 0 —-> Server
Client <– 置SYN标志 置ACK标志 序列号 = K, 确认号 = J + 1 <– Server
Clinet –> 置ACK标志 序列号 = J + 1,确认号 = K + 1 –> Server

链接建立后,接下来Client端发送的数据包将从J + 1开始,Server端发送的数据包将从K + 1开始,这里要说明的是:建立链接时,Client端宣称自己的初始序列号是J,Server端宣称自己的初始序列号是K,但是建立连接后的数据包却各自中初始序列号+1开始,这是因为SYN请求本身需要占用一个序列号。

在数据传输阶段,按照常理应用层数据的传输是这样的:(我们假定建立连接阶段Client端最后的确认包中序列号 = 55555, 确认号 = 22222)
Client –> 置PSH标志,置ACK标志 序列号 = 55555, 确认号 = 22222,数据包长度 = 11 —> Server
Client <– 置ACK标志,序列号 = 22222, 确认号 = 55566 (=55555 + 11),数据包长度 = 0 <— Server
Client <– 置PSH标志,置ACK标志 序列号 = 22223, 确认号 = 55566,数据包长度 = 22 <— Server
Client –> 置ACK标志,序列号 = 55566, 确认号 = 22244(=22222+22),数据包长度 = 0 —> Server

注:PSH标志指示接收端应尽快将数据提交给应用层。从我协议分析的经历来看,在数据传输阶段,几乎所有数据包的发送都置了PSH位;而ACK标志位在数据传输阶段也是一直是置位的。

但是实际我们在分析过程看到的却都是如下这样的:
Client –> 置PSH标志,置ACK标志 序列号 = 55555, 确认号 = 22222,数据包长度 = 11 —> Server
Client <– 置PSH标志,置ACK标志 序列号 = 22222, 确认号 = 55566 (=55555 + 11),数据包长度 = 22 <— Server
Client –> 置PSH标志,置ACK标志 序列号 = 55566, 确认号 = 22244 (=22222 + 22),数据包长度 = 33 —> Server
Client <– 置PSH标志,置ACK标志 序列号 = 22244, 确认号 = 55599 (=55566 + 33),数据包长度 = 44 <— Server

也就是说:数据接收端将上一个应答和自己待发送的应用层数据组合在一起发送了。TCP的传输原则是尽量减少小分组传输的数量,所以一般默认都开启"带时延的ACK"。一般实现中,时延在200ms。Nagle算法多用来实现"带时延的ACK",它要求一个TCP连接上最多只能有一个未被确认的小分组。在该分组的确认到达之前不能发送其他小分组。也就是说:发送端在发送一个分组后,需等待这个分组的ACK确认后,才可以进行下一个分组的发送。这样一来网络的传输效率被大大降低了。对于大数据块的传输来说,这样很多时候是难以忍受的。另一种拥塞控制策略被引入,那就是TCP的滑动窗口协议,滑动窗口协议是分组发送和分组确认不再同步,发送端可以连续发送n个分组,接收端同样也可以用一个确认包来一起确认这n个分组,通常n = 2。各种OS的TCP协议栈在实现上都是综合了Nagle算法和滑动窗口协议的,TCP层对应用层数据分组大小进行多次判断(一般分组大小都是和MSS做比较的),以在Nagle和滑动窗口协议之间抉择到底选择哪一种控制方式进行发送。"The Linux Network Architecture: Design and Implementation of Network Protocols in the Linux Kernel"一书介绍了Linux在TCP层上的设计和实现,当然最直观的还是去分析Linux源代码了。

拆除TCP连接过程用一句话表述就是:你关你的发送通道,我关我的发送通道(因为TCP是全双工)。当一方关闭发送通道后,仍可接收另一方发送过来数据,这样的情况就成为"半关闭"。然而多数情况下,"半关闭"使用的很少,而且半关闭需要SOCKET AIP支持在SOCKET上的shutdown(而不是调用close)。

正常的关闭流程是源于Fin报文的:
Client –> Fin ACK –> Server
Client <– ACK <– Server
Client <– Fin ACK — Server
Client –> ACK –> Server
发送Fin分组的一端会先将发送缓冲中的报文按序发完之后,再发出Fin;所以说Fin又叫做:orderly release。

异常的关闭流程是源于Rst报文的。一个典型的例子就是当客户端所要链接的服务器端的端口并没有程序在listen,这时服务器端的TCP层会直接发送一个Rst报文,告诉客户端重置连接。Rst报文是无需确认的。客户端在收到Rst后会通知应用层对方异常结束链接(需通过SOCKET API的设置才能得知对方是异常关闭)。

APR源代码分析-网络IO篇

“这个世界如果没有了网络就好比没有了石油、没有了电一样,是多么的可怕呀。”相信世界上已经有很多很多的人能够同意这种观点了,通过这个观点也可以看出网络在现代人们心中的地位。而运行在网络节点上的网络应用程序则是在幕后默默地为人们提供着服务。Apache Server就是其中一个典型的代表。而APR网络I/O库则像磐石一样支撑着Apache Server的运行。

APR网络I/O的源代码的位置在$(APR_HOME)/network_io目录下,本篇blog着重分析unix子目录下的各.c文件内容,其相应头文件为$(APR_HOME)/include/apr_network_io.h。

以程序员的视角来看待网络,这样我们可以忽略一些网络的基础概念。下面将循序渐进地接触网络,并说明APR是如何支持这些网络概念的。

一、IP地址 — 主机通信
我们熟知的并且每天工作于其上的因特网是一个世界范围的主机的集合,这个主机集合被映射为一个32位(目前)或者64位(将来)IP地址;而IP地址又被映射为一组因特网域名;一个网络中的主机上的进程能通过一个连接(connection)和任何其他网络中的主机上的进程通信。

1、IP地址存储
在如今的IPV4协议中我们一般使用一个unsigned int来存储IP地址,在UNIX平台下,使用如下结构来存储一个IP地址的值:
/* Internet address structure */
struct in_addr {
 unsigned int s_addr; /* network byte order (big-endian) */
};
这里值得一提的是APR关于IP地址存储的做法,看如下代码:
#if (!APR_HAVE_IN_ADDR)
/**
 * We need to make sure we always have an in_addr type, so APR will just
 * define it ourselves, if the platform doesn't provide it.
 */
struct in_addr {
    apr_uint32_t  s_addr;
};
#endif
APR保证了其所在平台上in_addr的存在。还有一点儿需要注意的是在in_addr中,s_addr是以网络字节序存储的。如果你的IP地址不符合条件,可通过调用一些辅助接口来做转换,这些接口包括:
htonl : host to network long ;
htons : host to network short ;
ntohl : network to host long ;
ntohs : network to host short.

2、IP地址表示
我们平时看到的IP地址都是类似“xxx.xxx.xxx.xxx”这样的点分十进制的。上面说过IP地址使用的是一个unsigned int整形数来表示。这样就存在着一个IP地址表示和IP地址存储之间的一个转换过程。APR提供这一转换支持,我们用一个例子来说明:
#include
#include
#include "apr_network_io.h"
#include "apr_arch_networkio.h"

int main(int argc, const char * const * argv, const char * const *env)
{
        apr_app_initialize(&argc, &argv, &env);

        char    presentation[100];
        int     networkfmt;
        memset(presentation, 0, sizeof(presentation));

        apr_inet_pton(AF_INET, "255.255.255.255", &networkfmt);
        printf("0x%x\n", networkfmt);

        apr_inet_ntop(AF_INET, &networkfmt, presentation, sizeof(presentation));
        printf("presentation is %s\n", presentation);

        apr_terminate();
        return 0;
}
APR提供apr_inet_pton将我们熟悉的点分十进制形式转换成一个整型数存储的IP地址;而apr_inet_ntop则将一个存整型数存储的IP地址转换为我们可读的点分十进制形式。这两个接口的功能类似于系统调用inet_pton和inet_ntop,至于使用哪个就看你的喜好了^_^。

二、SOCKET — 进程通信
前面提到过通过一个连接(connection)可以连接两个internet不同或相同主机上的不同进程,这个连接是点对点的。而从Unix内核角度来看,SOCKET则是连接的一个端点。每个SOCKET都有一个地址,其地址由主机IP地址和通讯端口号组成。一个连接有两个端点,这样一个连接就可以由一个SOCKET对唯一表示了。这个SOCKET对是这个样子的(cliaddr:cliport, servaddr:servport)。

那么在应用程序中我们如何获取和使用这一互联网上的进程通讯利器呢?每个平台都为应用程序提供了一套SOCKET编程接口,APR又在不同平台提供的接口之上进行了封装,使代码可以在不同平台上编译运行,而且易用性也有所提高。

1、SOCKET描述符
SOCKET属于系统资源,我们必须通过系统调用来申请该资源。SOCKET资源的申请类似于FILE,在使用文件时我们通过调用open函数获取文件描述符,类似我们也可通过调用下面的接口来获取SOCKET描述符:
int socket(int domain, int type, int protocol);
从Unix程序的角度来看,SOCKET就是一个有相应描述符的打开的文件。在APR中我们可以通过调用apr_socket_create来创建一个APR自定义的SOCKET对象,该SOCKET结构如下:
/* apr_arch_networkio.h */
struct apr_socket_t {
    apr_pool_t *cntxt;
    int socketdes;
    int type;
    int protocol;
    apr_sockaddr_t *local_addr;
    apr_sockaddr_t *remote_addr;
    apr_interval_time_t timeout;
#ifndef HAVE_POLL
    int connected;
#endif
    int local_port_unknown;
    int local_interface_unknown;
    int remote_addr_unknown;
    apr_int32_t options;
    apr_int32_t inherit;
    sock_userdata_t *userdata;
#ifndef WAITIO_USES_POLL
    /* if there is a timeout set, then this pollset is used */
    apr_pollset_t *pollset;
#endif
};
该结构中的socketdes字段其实是真正存储由socket函数返回的SOCKET描述符的,其他字段都是为APR自己所使用的,这些字段在Bind、Connect等过程中使用。另外需要提及的就是要分清SOCKET描述符和SOCKET地址(IP地址,端口号),前者是系统资源,而后者用来描述一个连接的一个端点的地址。SOCKET描述符可以代表任意的SOCKET地址,也可以绑定到某个固定的SOCKET地址上(在后面有说明)。我们如果不显式将SOCKET描述符绑定到某SOCKET地址上,系统内核就会自动为该SOCKET描述符分配一个SOCKET地址。

2、SOCKET属性
还是与文件对比,在文件系统调用中有一个fcntl接口可以用来获取或设置已分配的文件描述符的属性,如是否Block、是否Buffer等。SOCKET也提供类似的接口调用setsockopt和getsockopt。在APR中等价于该功能的接口是apr_socket_opt_set和apr_socket_opt_get。APR在apr_network_io.h中提供如下SOCKET的参数属性:
#define APR_SO_LINGER        1    /**< Linger */
#define APR_SO_KEEPALIVE     2    /**< Keepalive */
#define APR_SO_DEBUG         4    /**< Debug */
#define APR_SO_NONBLOCK      8    /**< Non-blocking IO */
#define APR_SO_REUSEADDR     16   /**< Reuse addresses */
#define APR_SO_SNDBUF        64   /**< Send buffer */
#define APR_SO_RCVBUF        128  /**< Receive buffer */
#define APR_SO_DISCONNECTED  256  /**< Disconnected */
… …
另外从上面这些属性值(都是2的n次方)可以看出SOCKET也是使用一个属性控制字段中的“位”来控制SOCKET属性的。
再有APR提供一个宏apr_is_option_set来判断一个SOCKET是否拥有某个属性。

3、Connect、Bind、Listen、Accept — 建立连接
这里不详述C/S模型了,只是说说APR支持C/S模型的一些接口。

(1) apr_socket_connect
客户端连接服务器端的唯一调用就是connect,connect试图建立一个客户端进程与服务器端进程的连接。apr_socket_connect的参数分别为客户端已经打开的一个SOCKET以及指定的服务器端的SOCKET地址(IP ADDR : PORT)。apr_socket_connect内部实现的流程大致如以下代码:
apr_socket_connect
{
 do {
         rc = connect(sock->socketdes,
                     (const struct sockaddr *)&sa->sa.sin,
                     sa->salen);
     } while (rc == -1 && errno == EINTR);   ——– (a)

 if ((rc == -1) && (errno == EINPROGRESS || errno == EALREADY)
                   && (sock->timeout > 0)) {
        rc = apr_wait_for_io_or_timeout(NULL, sock, 0); ——— (b) 注[1]
        if (rc != APR_SUCCESS) {
            return rc;
        }

 if (rc == -1 && errno != EISCONN) {
         return errno;   ——— (c)
     }
 
 初始化sock->remote_addr;
 
 … …
}
对上述代码进行若干说明:
(a) 执行系统调用connect连接服务器端,注意这里做了防止信号中断的处理,这个技巧在以前的文章中提到过,这里不详述;
(b) 如果系统操作正在进行中,调用apr_wait_for_io_or_timeout进行超时等待;
(c) 错误返回,前提errno不是表示已连接上。
一旦apr_socket_connect成功返回,我们就已经成功建立了一个SOCKET对,即一个连接。

(2) apr_socket_bind
Bind、Listen和Accept这三个过程是服务器端用于接收“连接”的必经之路。其中Bind就是告诉操作系统内核显式地为该SOCKET描述符分配一个SOCKET地址,这个SOCKET地址就不能被其他SOCKET描述符占用了。在服务器编程中Bind几乎成为了“必选”之调用,因为一般服务器程序都有自己的“名气很大”的SOCKET地址,如TELNET服务端口号23等。apr_socket_bind也并未做太多的工作,只是简单的调用了bind系统接口,并设置了apr_socket_t结构的几个local_addr字段。

(3) apr_socket_listen
按照《Unix网络编程 Vol1》的说法,SOCKET描述符在初始分配时都处于“主动连接”状态,Listen过程将该SOCKET描述符从“主动连接”转换为“被动状态”,并告诉内核接受该SOCKET描述符的连接请求。apr_socket_listen的背后直接就是listen接口调用。

(4) apr_socket_accept
Accept过程在“被动状态”SOCKET描述符上接受一个客户端的连接,这时系统内核会自动分配一个新的SOCKET描述符,内核为该描述符自动分配一个SOCKET地址,来代表这条连接的服务器端。注意在SOCKET编程接口中除了socket函数能分配新的SOCKET描述符之外,accept也是另外的一个也是唯一的一个能分配新的SOCKET描述符的系统调用了。apr_socket_accept首先在pool中分配一个新的apr_socket_t结构变量,然后调用accept,并设置新变量的各个字段。

4、Send/Recv — 数据传输
网络通信最重要的还是数据传输,在SOCKET编程接口中最常见的两个接口就是recv和send。在APR中分别有apr_socket_recv和apr_socket_send与前面二者对应。下面逐一分析。
(1) apr_socket_recv
首先来看看apr_socket_recv的实现过程:
apr_socket_recv
{
 if (上次调用apr_socket_recv没有读完所要求的字节数) { ———-(a)
  设置sock->options;  
  goto do_select;
 }

 do {
         rv = read(sock->socketdes, buf, (*len)); —— (b)
     } while (rv == -1 && errno == EINTR);
 
 if ((rv == -1) && (errno == EAGAIN || errno == EWOULDBLOCK)
                    && (sock->timeout > 0)) {
do_select:
         arv = apr_wait_for_io_or_timeout(NULL, sock, 1);
         if (arv != APR_SUCCESS) {
              *len = 0;
              return arv;
         }
         else {
              do {
                  rv = read(sock->socketdes, buf, (*len));
              } while (rv == -1 && errno == EINTR);
         }
     }  ———— (c)

 设置(*len)和sock->options;  ————-(d)
 … …
}
针对上面代码进行简单说明:
(a) 一次apr_socket_recv调用完全有可能没有读完所要求的字节数,这里做个判断以决定是否继续读完剩下的数据;
(b) 调用read读取SOCKET缓冲区数据,注意这里做了防止信号中断的处理,这个技巧在以前的文章中提到过,这里不详述;
(c) 如果SOCKET操作正在忙,我们调用apr_wait_for_io_or_timeout等待,直到SOCKET可用。这里我觉得好像有个问题,想象一下如果上一次SOCKET的状态为APR_INCOMPLETE_READ,那么重新调用apr_socket_read后在SOCKET属性中去掉APR_INCOMPLETE_READ,然后进入apr_wait_for_io_or_timeout过程,一旦apr_wait_for_io_or_timeout失败,那么就直接返回了。而实际上SOCKET仍然应该处于APR_INCOMPLETE_READ状态,而下次再调用apr_socket_read就直接进入一轮完整数据的读取过程了,不知道这种情形是否能否发生。
(d) 将(*len)设置为实际从SOCKET Buffer中读取的字节数,并根据这一实际数据与要求数据作比较来设置sock->options。

(2) apr_socket_send
apr_socket_send负责发送数据到SOCKET Buffer,其实现的方式与apr_socket_recv大同小异,这里就不分析了。

三、小结
APR Network I/O中还有对Multicast的支持,由于平时不常接触,这里不分析了。

注[1]:
/* in errno.h */
#define EISCONN         133     /* Socket is already connected */
#define EALREADY        149     /* operation already in progress */
#define EINPROGRESS     150     /* operation now in progress */

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats