最近看的《网络是怎样连接的》非常有趣,真的是 “计算机网络概论” 图解趣味版。
本文写写数据从网卡到应用的过程,内容与图片很多整理自《网络是怎样连接的》、《Tomcat内核设计与剖析》,有的图片因清晰度不够我进行了重绘。
本文围绕这张图从下至上展开。假设一个HTTP请求的数据到达网卡,那数据是如何被层层处理并到达应用呢?
网卡(Network Adapter),也称网络适配器,是一个 硬件设备,有全球唯一的 MAC(Media Access Control)地址,MAC地址在网卡生产时就被烧制在ROM中,网卡初始化时恢复到计算机中。
网卡收到的数据是 光信号或电信号,然后将其还原成 数字信息(1和0组成)。
下图是还原的数字信息结构。
根据 FCS(帧校验序列,Frame Check Sequence) 校验数据,判断数据在传输过程是否因噪音等影响导致信号失真,从而导致数据错误,需要丢弃这种无效的数据包。
然后 检查 数据包中MAC头部中的 接收方的MAC地址,若不是发给自己,则丢弃数据包;若数据包是发给自己,则将数字信息保存到网卡内部缓冲区。
以上过程网卡自行搞定,不需要CPU参与,CPU也不知道数据包的到达。
硬件需要驱动程序来控制,就像电脑需要操作系统一样,而网卡驱动就是CPU控制和使用网卡的程序。
网卡处理完数字信号后,接下来的数据接收需要CPU参与,此时网卡通过中断将数据包达到的事件通知给CPU。接着,CPU暂停手头工作,开始用网卡驱动来干活。
从网卡缓冲区读取接收到的数据根据MAC头部的以太类型字段判断协议种类并调用处理该协议的软件(即协议栈)通常我们接触的以太类型是 IP协议,因此会调用TCP/IP协议栈来处理。
因各层协议看上去像堆叠状态,也就取名”协议栈”。 像TCP、UDP、IP等协议都是规范,而协议栈则是实现各类协议的网络控制软件。例如:Windows、Linux各自对协议进行了实现,因此不同系统之间能够通讯,与JVM跨平台原理一致。
当MAC头部以太类型为 IP 协议时,网卡驱动数据包交给TCP/IP协议栈来处理。
IP模块会检查IP头部以判断数据是不是发给自己判断数据包是否分片,如果分片则缓存起来等待分片全部到达再还原成数据包根据IP头部的协议号字段,将包转给TCP模块或UDP模块处理下面是IP头部的部分字段。
下面是TCP报文首部结构。
TCP模块会根据 标志位 来进行不同处理,假设服务端收到该报文,会进行如下处理:
如果 SYN=1,表示这是请求连接的包。 首先检查接收方端口号,然后检查有没有与该端口号相同且处于等待连接状态的套接字。如果没有,则返回错误通知的包;如果有,则为这个套接字复制一个新副本,将发送方IP、端口等必要信息写入套接字,同时分配用于发送缓冲区和接收缓冲区的内存空间。最后返回给数据客户端,客户端会再次确认,这属于TCP连接三次握手的一部分。如果是正常数据包,TCP模块需要检查该包对应的套接字。然后提取出数据,存放到缓冲区。此时,如果应用程序调用socket的read(),数据就可以转交给应用程序了。如果应用程序不来获取数据,数据则一直保存在缓冲区中。在上述处理过程中,不同协议层会逐层处理,剥洋葱一样剔除协议头部,将数据转交到上层。而发送数据时,TCP、IP等也会一层层的为数据包加上头部。
最后,数据就到应用层,应用层通过socket来操作数据,下面说说socket。
socket 译为套接字,由加州大学伯克利分校的研究人员在20世纪80年代早期提出,因此也叫伯克利套接字。其研究人员使得套接字接口适用于任何底层协议,首个实现的协议就是针对TCP/IP。
从不同角度来看,其含义不同:
对应用层来说,可通过 socket 与内核中的网络协议栈通信。应用不能直接使用协议栈,更不能控制网卡驱动。所以 socket 提供了网络编程的系统调用接口。从Linux文件系统来说,socket 是一个打开的文件。下面命令找到的就是一个套接字类型的文件。 $ find / -type s/run/docker.sock在普通 Java 应用中的文件描述符文件夹中也可以看到,下图都是一些指向socket的软链接。
$ cd /proc/25693/fd$ ls -l | grep socket从Linux内核来说,socket 是一个通信的端点(Endpoint)。
常说 “连接”,通过C/S两端的socket真的是建立了一个物理通道吗?
连接实际上指的是通信双方的信息,套接字中记录了这些必要信息。下面是两个连接信息,包含了通信双方的IP、端口信息。
因此,套接字也可以认为是一个概念,它包含了通信双方的信息。
文件描述符(File Description)简称fd,是一个正整数,起到一个文件索引的作用,保存了一个指向文件的指针。
每创建或打开文件都会返回一个fd(一个数字),其实主流操作系统将TCP/UDP连接也当做fd管理,每新建一个连接就会返回一个fd。
应用程序申请创建套接字,具体实现由协议栈来搞定。协议栈首先会分配用于存放一个套接字所需要的内存空间,然后往其中写入控制信息(双方IP、port等信息)。套接字刚创建时,数据收发还没有开始,因此需要写入初始状态的控制信息。
接下来,需要将这个套接字的fd告诉应用程序。收到fd后,应用程序再向协议栈委托收发数据时,就要提供fd。
另,服务端在接收数据时,每来一个新连接,都会拷贝当前处于等待连接状态的socket,然后写入控制信息,而原先的socket则继续等待新的连接。
一个最简单的网络编程例子。
服务端:
// 创建套接字并绑定到8080端口 ServerSocket serverSocket = new ServerSocket(8080); while (true) { // 将套接字设置为等待连接状态,并阻塞线程 Socket socket = serverSocket.accept(); // 读数据时,其实要向协议栈提供套接字fd, 读取数据 System.out.println(IOUtils.toString(socket.getInputStream(), "utf8")); }客户端:
// 创建客户端套接字(会有fd, 完成TCP握手) Socket socket = new Socket("127.0.0.1", 8080); // 使用套接字收发数据 socket.getOutputStream().write("Hello World".getBytes());
应用通过socket通信,socket保存了通信双方信息,相当于一个连接信息。当并发量大时,比如 C10K 即单机1W并发连接,1W连接也对应着1W个fd。那如何判断哪些连接有新数据呢?
传统IO模型一个连接一个线程处理,然后遍历连接,CPU光遍历连接就已经满负荷。耗费大量线程,频繁切换线程环境,只适合低并发应用。进一步,可以一个线程管理多个socket(也就是多个fd),这也是NIO中的select机制。虽然降低了线程数,提高了并发能力,但遍历的瓶颈一直都在。问题最终由异步机制搞定。linux内核2.6版本提出了新的多路复用机制 epoll,套接字提供了回调函数,内核从网卡读取数据后就会回调该函数。下面是《Tomcat内核设计与剖析》的一张图。
应用层开发人员,比如Java工程师,更多的会好奇数据如何从Tomcat到Servlet,这个过程这些应用层框架是如何处理数据的。
信息技术迅猛发展的这些年,虽然应用层技术更新很快,但这些底层设施一直非常经久耐用,非常经典。所以多学习些Linux基础、计算机网络等基础知识,也大有裨益。