下面这篇文章是参考"骏马金龙"博客中
不可不知的socket和TCP链接过程 http://www.javashuo.com/article/p-vjuhpvth-cv.html
这篇博文对个人启发很大,但文中比较核心一些东西说明的不是很是详细,致使整片文章对于初学者仍是
难度太大,这篇文章引用了部分该文中的内容,主要是为了可以让整篇文章能连贯,一便让更多想要深刻计算机
原理的人,有更多参考,能更快明了一个Client发送一个访问Web服务器的请求,到底WebServer如何处理接收
到请求报文,并最终完成整个通讯过程,这篇文章将深刻说明,但因为本人能力有限,不少理解是本身经过对CPU
内存,网络的理解推演出来的,没有很确凿的证据证实,也但愿大牛路过,多给予指教,不要让我误人子弟了。
很是感谢!同时也很是感谢认真的学习道友"骏马金龙",让我能将积累的知识连贯起来,用最简单的语言尽最大
可能将这个复杂过程,说的尽量清楚。html
说明:node
此篇是TCP深刻刨析的上篇,核心点是说明Clinet和Server如何完成TCP三次握手。
TCP深刻刨析是下篇,核心点是说明Client和Server是如何完成数据传输的。linux
首先来一张TCP socket通讯过程图编程
socket()函数:
就是生成一个用于通讯的套接字文件描述符sockfd(socket() creates an endpoint for communication and returns a descriptor)。
这个套接字描述符能够做为稍后bind()函数的绑定对象。
socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); 这是socket函数在内核源码中定义的函数,从这个结构中可看出来,
socket函数仅建立了一个socket结构文件,但并无关联任何IP和端口,其中AF_INET是AddressFamily(地址族)是Inernet
网络地址,SOCK_STREAM,是指定socket接受数据格式,共有两种,一种是stream(流,),另外一种是dgram(数据报),最后
一个是协议TCP/UDP.不少时候,内核其实根据AF_INET和SOCK_STREAM就能够推演出应该使用TCP,所以
IPPROTO_TCP/UDP可省略,但这并非好习惯。
bind()函数:
服务程序经过分析配置文件,从中解析出想要监听的地址和端口,再加上能够经过socket()函数生成的套接字sockfd,
就可使用bind()函数将这个套接字绑定到要监听的地址和端口组合"addr:port"上。绑定了端口的套接字能够做为listen()
函数的监听对象。绑定了地址和端口的套接字就有了源地址和源端口(对服务器自身来讲是源),再加上经过配置文件中
指定的协议类型,五元组中就有了其中3个元组。即:{protocal,src_addr,src_port} 可是,常见到有些服务程序能够
配置监听多个地址、端口实现多实例。这实际上就是经过屡次socket()+bind()系统调用生成并绑定多个套接字实现的。
所谓五元组,即 {protocal, Server_src_addr, Server_src_port, Client_dest_addr, Client_dest_port}
当还处于监听状态时,套接字称为监听套接字,此时它只包含三元组。
TCP报文结构:缓存
当客户端与服务器经过三次握手建链,同步了TCP保障会话的状态序列号(Sequence Number),
窗口大小(Window Size)以及Client的源IP和源端口,这是服务器端的监听套接字就能够构建成完整的
专用链接套接字,即五个关键元素组成了新的socket。固然Client也会生成本次于Server通讯的专用
链接套接字。注意:是专用链接套接字
补充说明:
TCP之因此称为可靠链接就是因为其包含Sequence number和Acknowledgment number,
TCP经过它们来实现数据传输的确认机制,简单理解以下:
#这里为方便说明,将汉字,单词 和 点直接简单认为是1个数据.
#ack=1,是假设Server三次握手最后一次传递的seq=0,我要对它发给个人数据作确认
Client----[我想获取index.html, seq=7,ack=1]----------------->Server
Server---[index.html{欢迎来到咱们的网站.},seq=12,ack=8]--->Client
Server---[{学习园地.},seq=17,ack=8]--------------------------->Client
这里为了方便说明,但并不是彻底准确
假设Server要传递给Client的数据量很大,被拆分红了多个包,这里仅以两个为例说明,
第一个报文总长度为12个字,假设已经满了,而后又生成了第二个数据包长度5个字,
这时它们的序列号须要注意,你会发现其实序列号就是传输数据量的说明,即我此次给你传了
多少数据,假设网络情况不是很好,第一个包在传输过程当中丢失了,Client收到了第二个报文,
Client解包后,发现这个序列号是13-17,而后,Client就会给Server发送一个ACK报文,这个报文
仅对13-17段数据作确认,Server收到后,过了最大RTT(数据包从Server到Client直接最大往返时间)
发现Client仍是没有对1~12这段数据作确认,因而知道第一个报文在网络中传输时丢包了,而后
就会仅将1~12这段数据再次发送给Client,当Server在RTT以前收到Client对这段数据的ACK,
则认为通讯完成,等待Client后续的请求,若长链接超时,Client没有再次发起请求,则Server将
主动断开链接,而后进入TCP的四次挥手阶段。
注意:这里确认13~17这段数据时是这样的:
Client--[seq=9,ack=18]------------------------------------>Server
#Server是发送数据者,它知道本身发了那些数据,Server看到18,对比发送列表,1~12,13~17,
这个ACK包确认的必定是第二个包,由于第一个包的ACK seq应该是13,而不是17.
说明:
实际上一个数据包的大小是由网卡上的MTU值决定,默认MTU是1500个字节,去掉TCP/IP协议栈
封装头部大概是1446个字节,而后还要去掉上层不一样协议封装的头部字节数,剩下才是这个数据包实际
能装多少数据,但这个仍是额定数据量,正式往数据包中装数据还要看窗口大小(window size),它是
Client和Server之间协商出来的,由于Server或Client均可能由于某些缘由接收不了不少数据,所以为了
能通讯,在通讯前是必须互相告知本身一次最多能接受多少数据的。
MTU(最大传输单元): 如今网络中全部设备都默认是1500字节,一个数据包在网络中传输最大必须
是1500字节,只要超过就会被网络设备切片后,从新封装再发生,但前提是IP包中容许分片
位是1,即容许分片,不然该数据包将被丢弃。
listen()函数:
int listen(int sockfd, int backlog); 这是内核源码中listen函数的定义,sockfd 就是bind函数关联后的套接字文件描述符。
backlog:
Linux Kernel2.2之前,backlog 用于设置上图中未完成和已完成队列的最大总长度(其实是只有一个队列,
但分为两种状态),实际目前这种是BSD衍生的一种套接字类型,它采用了一个队列,在这单个队列中存放
3次握手过程当中的全部链接,可是队列中的每一个链接分为两种状态:syn-recv和established。服务器
Linux Kernel2.2开始,这个参数只表示已完成队列(accept queue)的最大长度,而/proc/sys/net/ipv4/tcp_max_syn_backlog
则用于设置未完成队列(syn queue/syn backlog)的最大长度。/proc/sys/net/core/somaxconn则是硬限制已完成队列
的最大长度,默认为128,若是backlog参数大于somaxconn,则backlog会被截短为该硬限制值。
参考下图: 此图来自http://www.javashuo.com/article/p-emznafsw-bk.html网络
说明: 图中提到的分片,实际应该称为IP报文,不要与IP分片混淆。
下面说明listen函数,已httpd为参考来讲明,这样更便于说明问题;另外下面说明并不必定彻底正确,多数是根据个人理解
推演出来的,因此下面说明仅供参考,但愿大牛路过,能给予指点,不要让我误人子弟了。
当httpd进程被启动后,它经过读取配置文件,获取到要监听的地址和端口,完成socket和bind后,就进入到listen函数了,
listen系统调用会向内核管理的socket set(集合)中注册本身的监听套接字,而后返回,此时做为httpd的perforce模型的话,
它将调用select()函数,此函数会发起系统调用来获取内核管理的socket set,并检查其中本身所关心的socket是否处于就绪态,
便是否变为可读,可写,异常; 若非就绪态,就继续过会儿在检查,这期间httpd将处于阻塞状态的,直到httpd所分配的CPU时间片
都耗尽,CPU会将httpd从CPU上转入内存处于睡眠,等待下次被调度到CPU上执行;
假设如今有用户发起访问,则大体过程以下:
首先,服务器网卡接收到数据流后,会马上给CPU发送中断信号,CPU收到后,会当即将手头正在处理的事务
所有挂起,并当即检测网卡上是否有DMA芯片,如有就直接发送指令告诉DMA芯片,你将数据流复制到指定的DMA_ZONE的
核心内存区中的指定地址段中,而后DMA芯片就开始复制,CPU从新恢复挂起的进程,继续处理,当网卡DMA芯片再次发送
中断告诉CPU我复制完了,此时CPU将当即挂载正在处理的进程,并激活内核,内核得到CPU控制权后,启动TCP/IP协议栈
驱动处理接收到的数据包,解封装后,发现是一个要访问本机80套接字的SYN请求,因而内核检查socket set发现有这样的监听
套接字,因而内核将SYN数据复制到Kernel buffer中,进行进一步处理(如判断SYN是否合理),而后准备SYN+ACK数据完成
后通过TCP/IP协议栈驱动封装IP头,链路层帧头,最终这个数据被写入到send buffer中,并当即被复制到网卡传送出去,同时
内核还会在链接未完成队列(syn queue)中为这个链接建立一个新项目,并设置为SYN_RECV状态。
接着内核进入睡眠,CPU检查被挂起的进程的时间片是否耗尽,没有就将其调度到CPU上继续执行,不然继续将其它用户空间
的进程调度到CPU上执行,当Client收到Server的响应后,回应了ACK报文,Server的网卡收到后,又会继续上面的动做,CPU
会再次挂起正在处理的进程,并将数据接收进来,复制到指定的kernel buffer中(注:DMA_Zone也是Kernel buffer的一部分,
这里不严格区分它们的区别) 接着CPU会唤醒内核,又它调度TCP/IP协议栈驱动处理收到的数据包,当解封装后,发现是一个
ACK报文,而且数据段大小为0,这时内核会去检查未完成链接队列,若找到与该ClientInfo(客户端信息)一致的链接信息,则
将该链接从未完成链接列表中删除,而后在已完成链接队列中插入该链接信息,并标记状态为ESTABLISHED,接着将内核中
维护的socket set中80监听套接字的状态更为为可读,随后内核让出CPU,进入睡眠,CPU继续将挂起任务载入CPU上执行,
若CPU时间片用完,则将其转入内存进入睡眠,继续下一个用户空间的进程,假设此时调度httpd进程到CPU上执行,它依然
是发起select系统调用,此时内核被唤醒,httpd被挂起,内核将根据select的要求,返回内核中socket set的所有状态集,
而后,内核进入睡眠,CPU将httpd调入CPU上执行,此时select开始遍历获取到的socket set集合,当找到本身监听的
socket状态为可读时,它将当即解除阻塞,并调用accept()系统调用,此时内核再次被唤醒,而后根据accept的要求,将
已完成链接队列中与本身创建链接的ClientInfo信息取出来,并删除队列中的信息,而后根据监听套接字,生成一个新的
专用链接套接字,接着将该套接字注册到内核管理的socket set中,最后将该专用链接套接字返回,接着内核进入睡眠,
CPU再次调度httpd进入CPU上执行,httpd获取到专用链接套接字的文件描述符后,将其分配其中一个子进程,由子进程
来完成与该用户的后续数据交互,主进程则继续监控监听套接字。
若此时httpd的CPU时间片用完了,CPU将会把httpd转入内核睡眠,而后继续其它用户空间的进程;假如此刻Client
请求网站主页的数据包到达Server网卡了,网卡依然会采用上面的动做,内核依然会被唤醒,而后内核会调度TCP/IP协议
处理数据包,当解封装后,发现这个数据包是ACK报文,而且数据段大于0,此时内核知道这是一个已经完成的链接请求数据包,
因而根据请求报文中的 {源IP,源Port,目标IP,目标Port,协议} 去遍历查找socket set,若找到对应的专用socket,则将数据
拷贝到socket buffer中,并将专用socket的状态设置为可读,而后,内核进入睡眠,CPU继续挂起的任务,当httpd的子进程
被调到到CPU上执行时,它经过select系统调用去检查本身监听的专用套接字时,发现本身关心的套接字为可读,因而当即
解除阻塞,调用recvform系统调用,读取数据,此时内核会被唤醒,完成将socket buffer中的数据拷贝到该进程的的内存
空间中,而后内核进入睡眠,CPU将httpd子进程调度到CPU上继续执行,httpd子进程读取数据,分析后知道用户要请求
网站主页资源,因而再次发起系统调用,获取磁盘中存储的主页数据,此时内核被唤醒,而后内核调度磁盘驱动,若该磁盘
上面有DMA芯片,则内核会直接告诉DMA芯片,你将磁盘中指定柱面,指定扇区,指定磁道上的数据复制到指定的DMA_ZOME
中指定的内存区中,而后,内核进入睡眠,CPU继续调度其余进程到CPU上执行。 可是若磁盘上没有DMA芯片,那么内核
将自行调度磁盘驱动读取磁盘数据,并等待磁盘驱动完成数据从磁盘拷贝到内核kernel buffer中,再这期间kernel将被阻塞,
直到数据拷贝完成,而后,内核进入睡眠,CPU再将httpd子进程调度到CPU上执行,若时间片用完,则再将其调度到内存
睡眠,不然就继续让httpd子进程执行,假如此时时间片没有耗尽,httpd子进程将会再次发起系统调用,让内核将kernel buffer
中的数据拷贝到本身的内存空间中,因而CPU再次将其挂起,内核完成拷贝后,再次进入睡眠,httpd子进程再次被调到
CPU上执行,而后httpd子进程开始将数据封装上http首部,构建成响应报文后,再次发起系统调用,让内核将数据复制到
send buffer中,此时httpd子进程被挂起,内核开始将进程内存空间中的数据拷贝到内核内存空间中的send buffer中,准备
调度TCP/IP协议栈驱动对报文作TCP首部,IP首部,链路层帧头等封装,最终这个数据包构建完成,被内核发往网卡的缓冲区
中等待发送,固然若网卡上有DMA芯片,内核依然可以让DMA芯片来复制数据,完成发生,本身就去睡眠。若没有就只能本身干。
以上描述就是HTTP通讯的一个缩影。【文末有两段代码,可合起来看,基本就是相似httpd的代码实现,摘录仅为了方便理解
不至于太空洞,而感受没有依据似的。】
注:
上面描述了select()系统函数,这是最简单的一种多路复用I/O调用,还有poll和epoll,这两个系统调用,也属于多路复用I/O
模型,但poll和select基本相似,因为能力有限,对它们之间的区别理解很浅薄,仅知道它们在获取socket set时,彷佛select
是用列表方式,而poll是链表方式,彷佛于此有关,致使poll没有最大1024的限制,而select由于在kernel编译时,就设置其
最大值为1024,即只能同时接收1024个链接,但具体理解不深,如有大牛知道,也但愿能分享博文。
下图是select模型系统调用说明图:并发
关于epoll个人理解也不是很深入,仅作如下说明,但理解poll和epoll的前提,仍是要先理解select.
下图是epoll这种基于事件驱动的I/O模型工做示意图:异步
简图:socket
更高级的网络I/O模型是AIO,其实现原理很便于说明,但细节我理解不深:
异步非阻塞I/O模型
相对于同步IO,异步IO不是顺序执行。用户进程进行aio_read系统调用以后,不管内核数据是否准备好,都会直接
返回,不会阻塞用户进程,而后用户进程能够继续接受新的链接请求。等到socket数据准备好了,内核会直接复制
socket buffer中的数据复制到用户进程空间后,内核才会找到用户进程留下的联系方式(即:通知信号)向进程发送通知。
能够看到IO的两个阶段,进程都是非阻塞的。 Linux 内核提供了AIO库函数的实现,可是用的不多。目前有不少开源
的异步IO库,例如libevent、libev、libuv。异步过程以下图所示:
【注意:这里方式实现起来极其复杂,可是Nginx是彻底支持这个方式的。】
简图:
另外说明:
1. 助理:即DMA机制
就拿硬盘来讲,CPU须要读磁盘中的一段数据时,它发现磁盘支持DMA,则CPU会受权给DMA容许访问系统总线,
并告诉它将磁盘中的那部分数据放到内存中指定的地方,接着CPU就无论了,由DMA来完成数据搬运,并在完成时,
向CPU发中断 报告完成。
这里须要注意:
1. CPU经过32根线才完成了访问4G的内存空间,那DMA要访问内存也须要32根线吗?
固然不是,DMA的总线是很窄的,所以为了让DMA可访问内存,系统在设计时,就将RAM中低地址中
的一段空间预留个DMA使用,它一般是16M;其中RAM的起始区中第一个1M区域是固定给BIOS使用的,
由于CPU在制造时,就设定了只要开机,CPU首先去读取RAM中0地址开始的连续的1M区域,来完成
处理BIOS映射到里面的指令,实现开机自检。DMA设计时也是会去访问内存中固定的地址区域,实现
高效传输。但须要注意的是,每次系统要DMA工做前,都会事先腾出DMA将访问的内存区域。
2.当前httpd, Nginx等Web服务器都已经有更先进的技术,如sendfile,mmap,这些机制,可以让上面繁琐
磁盘数据拷贝过程变得更加高效,
这里不展开说明,仅简单说明以下:
Nginx支持Sendfile方式响应静态网页:Linux中支持 sendfile 和 Sendfile64
正常状况下:当用户发来请求后,内核收到网卡中断处理数据流,判断为http数据流,告知将该数据流将给监听在
80 Socket的应用程序这时,Nginx的Master进程监听到链接请求,并将该链接请求将给Worker进程,来创建
Http会话响应用户,经过解析请求发现用户请求的是静态网页,接着Worker请求向内核发起I/O请求,内核为
该请求准备Buffer,并向磁盘请求数据,一般由DMA(直接内存访问)控制芯片接收内核请求(请求中一般会包含
让DMA将数据Copy到那段Buffer空间),并从磁盘中读取数据,并Copy到Buffer中,完成后,向内核发送中断信号,
通知内核数据准备完成,接着内核将数据copy到Nginx的进程内存空间(注:Nginx默认采用epoll,信号驱动I/O
模型)完成后,通知Worker进程Worker进程对数据作处理后,接着又向内核发起请求,要求内核将处理后的
数据封装http头,TCP头, IP头, 并最终发给用户。
Sendfile方式:在这种方式下,当网卡接收到数据流后,发送中断给内核,内核处理后通知网卡将数据发给80 Socket
上监听的应用程序接着,Nginx的Master进程监听到链接请求,并负责向该请求分配Worker进程,来创建
HTTP会话链接,Worker进程分析该请求后,是要请求静态网页数据,接着向内核发起I/O请求,并告知
内核这是请求静态页面的,你直接将数据封装HTTP包响应用户便可,不需在把数据给我了。接着内核向
磁盘请求数据,获得数据后,直接将用户请求的数据封装HTTP头,TCP头,IP头,数据链接层头,
完成后,直接响应用户。
对比两种方式不难发现,Sendfile方式更高效,由于它去掉了I/O请求中两次COPY的过程,在高并发的场景中是很是高效的。
注意:sendfile:仅支持很小的文件直接在内核封装并响应用户,而sendfile64则支持更大的文件在内核中直接封装并响应用户。
【 注意:内核任什么时候候与进程交换并传递数据时,采用的方式都时Copy,除非指定使用共享内存。】
这段是摘自参考文章,这篇博文详细介绍了MMAP:
http://www.javashuo.com/article/p-ctyjjbvt-m.html
对linux文件系统不了解的朋友,请参阅我以前写的博文《从内核文件系统看文件读写过程》,咱们首先简单的回顾一下 常规文件系统操做(调用read/fread等类函数)中,函数的调用过程: 一、进程发起读文件请求。 二、内核经过查找进程文件符表,定位到内核已打开文件集上的文件信息,从而找到此文件的inode。 三、inode在address_space上查找要请求的文件页是否已经缓存在页缓存中。若是存在,则直接 返回这片文件页的内容。 四、若是不存在,则经过inode定位到文件磁盘地址,将数据从磁盘复制到页缓存。以后再次发起 读页面过程,进而将页缓存中的数据发给用户进程。 总结来讲,常规文件操做为了提升读写效率和保护磁盘,使用了页缓存机制。这样形成读文件时须要 先将文件页从磁盘拷贝到页缓存中,因为页缓存处在内核空间,不能被用户进程直接寻址,因此还须要将 页缓存中数据页再次拷贝到内存对应的用户空间中。这样,经过了两次数据拷贝过程,才能完成进程对 文件内容的获取任务。写操做也是同样,待写入的buffer在内核空间不能直接访问,必需要先拷贝至内核 空间对应的主存,再写回磁盘中(延迟写回),也是须要两次数据拷贝。 下面是个人理解,仅供参考: 而使用mmap操做文件中,建立新的虚拟内存区域和创建文件磁盘地址和虚拟内存区域映射这两步,没有 任何文件拷贝操做。而以后访问数据时发现内存中并没有数据而发起的缺页异常的过程(即:从物理内存的缓冲区 中查找是否有须要的已打开文件,若无),则会经过已经创建好的映射关系,只使用一次数据拷贝,就从磁盘 中将数据传入内存的用户空间中,供进程使用。 总而言之,常规文件操做须要从磁盘到页缓存再到用户主存的两次数据拷贝。而mmap操控文件,只须要 从磁盘到用户主存的一次数据拷贝过程。说白了,mmap的关键点是实现了用户空间和内核空间的数据直接 交互而省去了空间不一样,数据不通的繁琐过程。所以mmap效率更高。 下面这部分代码是我从网上摘录的,仅为方便理解socket,bind,listen和select#include <sys/socket.h>#include <stdio.h>#include <string.h>#include <netinet/in.h>#include <stdlib.h>#include <arpa/inet.h>int main(int argc,char** argv){ int ret; int listenfd = socket(AF_INET,SOCK_STREAM,0); if (listenfd == -1) { printf("socket error\n"); return -1; } struct sockaddr_in serveraddr; memset(&serveraddr,0,sizeof(serveraddr)); serveraddr.sin_family = AF_INET; serveraddr.sin_addr.s_addr = htonl(INADDR_ANY); serveraddr.sin_port = htons((unsigned short)(atoi(argv[1]))); ret = bind(listenfd,(const sockaddr*)&serveraddr,sizeof(serveraddr)); if (ret == -1) { printf("bind error,ret = %d\n",ret); return -1; } int backlog = atoi(argv[2]); ret = listen(listenfd, backlog); printf("backlog = %d,ret =%d\n",backlog,ret); if (ret == -1) { printf("listen error,ret = %d\n",ret); return -1; } for(;;) {} #这部分,应该可填充下面这段代码,因此可参考下面的代码。【我并非很懂】 return 0;}============这是另外一篇中的部分代码摘录============= /* 假定已经创建UDP链接,具体过程不写,简单,固然TCP也同理,主机ip和port都已经给定,要写的文件已经打开 sock=socket(...); bind(...); fp=fopen(...); */ while(1) { FD_ZERO(&fds); //每次循环都要清空集合,不然不能检测描述符变化 FD_SET(sock,&fds); //添加描述符 FD_SET(fp,&fds); //同上 maxfdp=sock>fp?sock+1:fp+1; //描述符最大值加1 switch(select(maxfdp,&fds,&fds,NULL,&timeout)) //select使用 { case -1: exit(-1);break; //select错误,退出程序 case 0:break; //再次轮询 default: if(FD_ISSET(sock,&fds)) //测试sock是否可读,便是否网络上有数据 { recvfrom(sock,buffer,256,.....);//接受网络数据 if(FD_ISSET(fp,&fds)) //测试文件是否可写 fwrite(fp,buffer...);//写入文件 buffer清空; }// end if break; }// end switch }//end while }//end main 仔细结合上面对listen,select函数的说明,在参考这个两段代码,再去细细思考整个TCP通讯过程,相信你会有本身的理解。本人对C编程并不熟悉,但大概能看明白,所以不作说明,请自行选看。