Linux内核工程导论——网络:Socket

网络架构

         我们通常见到的网络是以太网络和无线网络,但是linux是个大而全的操作系统,其支持的无线网络很多。主要有:业余无线电、CAN网络、红外线(IrDA)、蓝牙(Bluetooth)、WiFi、WiMAX、RF开关、Plan 9、NFC等。以太网的很多东西在其他网络中也是通用的。我们主要讨论常见的以太网。内核编译时会给出很多网络部分的选项,这些选项可以控制打开和关闭某些网络功能,但并不意味着选项中出现的就是linux支持的全部网络功能。

         计算机网络一般是分层讨论的。从OSI将计算机网络分层后,如果不使用分层的方法,网络将无法阐述。分层让逻辑清楚,易于理解,更重要的是易于实现和工业化(每个公司或软件都可以集中注意力在自己所要实现的某个层次的某个功能)。

         常见的有线物理层,也即使用的传输介质有网线、电视线(同轴电缆)和光纤。每一种物理传输线都有其特性,例如可用的频段,吞吐量,传输损耗等,根据其不同的情况要选用不同的调制解调方式和划定不同的传输信道。像对网络进行分层一样,所有的传输介质都对可以在线缆中使用的频段进行了划分,叫做信道。因此信道管理也是每个传输介质所要对应的逻辑部分,例如信道动态的添加删除、分配、冲突避免等逻辑上的操作构成了MAC层。所以可以见到推广某一物理传输介质的组织,一般都会同时定义MAC层,有的甚至定义数据链路层。

         我们见到的很多网络协议族都是没有链路层的,例如以太网MAC层之上直接就是IP层,而有的却有,例如wifi。这是什么原因?这就涉及到链路层存在的意义。数据链路层是在信道之上建立的逻辑连接,这个连接不同于TCP层的连接,TCP层的连接目的是高速高效的传输数据,保证数据的稳定可达,而数据链路层的连接通常是用来控制可访问性、计费、认证等用来做链路监视和控制的。所以一个开放的网络,不需要链路层,因为你访问链路是自由的,不受别人控制。而如果使用ISP的服务,或者使用无线提供者的服务,你是否可以接入网络(IP数据包能否发送出去)取决于服务提供商是否允许你访问,这是数据链路层就是有意义的。

         而即使接入了网络,在众多节点中,你的数据包如何到达你想要它去到的地方?这就需要寻址。像地图里的各个地点,首先需要道路连接,然后旅行者还得知道如何选择道路才能正确的到达目的地。因此,这里面的3个要素:各个节点(地点)的命名(标志),节点之间的连接(道路)、以及如何选择线路。地图上通过观察两点之间的距离和路径可以直观的选择出来最短路径,但是有的路堵,有的路通畅,有的路贵,有的路便宜。网络中的节点也是各个节点的质量不一样,节点间的链路不一样。网络可以有分布式或者集中式的,为了容易控制,Internet网络路由上选择了分层的集中式,而有的局部网络可能就是分布式的。IP地址的网络部分(子网掩码规定的)代表了链路,后面的部分代表了节点。网络地址相同的,一般链路上是相连的,不同网络之间通过一个或多个节点互相联系,这个节点一般同时有两个网段的IP。如此便解决了前两个要素。第三个要素的解决是通过路由表,每个节点维护了路由表,记录了什么目的地址的数据包该转发给谁。至于这些路由信息如何得来,就是通过社会工程(实际组网的分配设置)和一些动态的路由协议(如RIP、OSPF)等交换通信得来。

         有了网络层完成寻址,数据包就可以到达对方的PC了,但是每个PC上都不是只有一个程序需要使用网络数据的。不同的程序的数据必须区分开,这就诞生了链路层的需求。链路层为每个IP地址(每个PC)之上又定义了端口概念,一个应用程序使用一个(或多个)端口,在数据包中写入了端口信息就可以被指定的程序所理解和处理。

         程序不只是用网络来发送无意义的数据的,他们发送和接收的内容是经过定制的可以被程序所理解的。这就是应用层协议,在链路层之上。

Socket

简介

         网络通信服务一般由操作系统提供,使用这个服务的一般是进程。前面我们知道,正常情况下应用层是建立在传输层之上的,也就是说程序使用的协议一般是传输层协议。传输层常见的有TCP和UDP,分别代表了保证质量的和不保证质量的两种类型。其他的还有SCTP(希望取代TCP)、DCCP(希望取代UDP)等传输协议。所以进程编程就是要选择合适的传输协议,然后调用传输协议进行数据通信。

         像每一层都给上一层提供统一的稳定的接口一样,传输层也给应用层提供了统一的调用接口,就是socket。不过更进一步,这个socket的概念后来被广泛的拓展,现在几乎变成了使用所有网络相关服务的接口了。就如同TCP/IP被用在了ATM网络上一样,好的技术规范是会被广泛采纳的。

类型与接口

         Socket有很多种类型,分别工作在不同的网络协议层,但是Socket的对外的函数和数据接口是基本一致的。常见的socket类型有用于直接发送IP数据包的Packet Socket,用户本机进程通信的unix domain socket,用于IPSec上通信的PF_KEYv2 socket,普通在TCP/UDP上通信的socket、用于虚拟机与主机通信的Virtual Socket,用于用户与内核通信的netlink,

         不同类型的socket的区别在于调用的网络服务不一样,但是操作接口都是一样的。

int socket(int family, int type, int protocol)

         socket究竟是指什么呢?所有操作系统为了有效的管理资源,并且能被用户程序有效的索引,都会为资源用数字编号命名,这叫做句柄。我们自己写用户进程可以用指针,但是内核与用户空间之间是不能用指针引用的。通过一个句柄的分配和查询,让无论是打开的文件还是生成的socket都可以唯一的确认。socket是一个句柄,对于内核来说,用户必须提供这个句柄才能使用socket相关的功能(如发送数据),所以生成socket句柄是所有用户进程编程的第一步。这个函数是

         #include<sys/socket.h>

         intsocket(int family, int type, int protocol)

         由于socket相关的调用是通用的,但是用户总得指定是使用什么样的传输协议,指定的方法就在这个函数。family指定协议族,比如TCP/IP协议族、或OSI协议族或者AppleTalk协议族,协议族的不同,决定了内核使用的整个协议栈都是不同的。type是因为所有的传输层协议无非只有两种,数据流式的和数据包式的。虽然本质上所有的数据都是通过数据包传输的,但是在传输层看来,如果数据包可以有序的,不遗漏的到达,那么就是数据流。因此数据流式的协议都会提供额外的传输控制,而数据包式的一般只是起到了不同端口定位不同程序的功能。因此常见的有SOCK_STREAM和SOCK_DGRAM两种取值。又由于socket的推广,所以其还支持SOCK_SEQPACKET(SCTP)、SOCK_RAW(IP)等类型。protocol就是指明具体的传输协议了。其实完全可以只用一个数来编号所有的传输协议,但是为了容易区分,还是分开了。

         由于UDP与TCP一个没有连接概念,一个有,又要拥有同样的对外接口函数,这样如何设计接口?目前的方法是按照TCP的需求设计接口,UDP只需要使用其中的一部分,然而即使调用了例如connect这种面向连接概念的接口,UDP也能正常处理甚至可以完成一定的功能。

         Socket是内核中的一个拥有状态的对象,刚创建出来的时候默认是CLOSED状态的,TCP调server用了listen函数之后会变为LISTEN状态,之后还会随着TCP连接的建立和关闭而切入到不同的状态。可以看出来socket虽然同时为TCP和UDP服务,但是主要是考虑TCP的需求的。UDP是socket提供服务的子集。

连接建立

         网络进程都是分为server和client两端的。server负责监听连接,client负责发起连接(特殊情况不考虑)。所以对于两端无论是UDP还是TCP来说需要的函数调用接口是不一样的。但是同一的是双方都需要先选择自己的IP地址和端口(因为无论是client还是server都可能有多个IP),这个函数是bind。然而,很多现在的socket实现都可以智能选择,所以不调用bind也是可以的,但是如果不成功的话可能就得自己手动选择了。但是对于server来说,随机选择别人怎么连你?所以server还是得调用bind的。

int bind(int sockfd, const struct sockaddr *myaddr,socklen_t addlen);

         第一个参数是要bind的套接字,第二个是地址和端口的结构体,第三个指明长度。其实不用指明长度内核也知道长度(结构体就是它定义的,它哪能不知道),这种啰嗦笑笑就好。

         端口和IP地址可以都指定,也可以都不指定(设0),也可以指定一个。如果不指定,内核随机选取了端口号,选择的这个端口号也不会被函数返回,必须要调用getsockname()调用手动获得。而不指定IP地址(指定为INADDR_ANY),内核会等到TCP建立连接或者UDP发出数据所使用的IP地址来设定作为socket之后使用的IP地址,但是主监听socket还会继续使用通配地址。这

一个函数可能有4种调用情况:

UDP server

只监听指定的IP:端口。未指定的随机选取

UDP client

只从指定的IP:端口发送数据。未指定的随机选取

TCP Server

只监听指定的IP:端口。未指定的随机选取

TCP client

只从指定的IP:端口发送数据。未指定的随机选取

服务器端

int listen(int socketfd, int backlog)

         这是唯一一个只可以由TCPserver调用的函数。因为这个函数的主要功能是识别3次握手。

         我们要分清的一点是socket是内核资源,listen操作也是内核完成的,内核完成3次握手完全不需要用户程序的参与。调用了listen就相当于告诉内核,为我监听网络中的TCP连接,完成了3次握手再叫我。之后进程就可以陷入睡眠了。

         内核既然要完成3次握手,那么就要考虑多个用户同时连接的情况。事实上还有SYNC flood攻击的情况。3次握手的设计导致内核在收到SYNC之后回复SYNC/ACK并且要等待客户端的继续回复。由于网络环境的不可靠,用户不一定会回复,回复的时间也不一定。所以内核为监听3次握手的socket维护了两个队列,一个是正在等待client最后回复的,一个是等到的表示已经成功建立。backlog参数没有固定的意义,各个版本甚至各个linux发行版的意义都不同,但都是用来计算这两个队列的大小的参数。

Linux下一般用proc文件系统的/proc/sys/net/core/somaxconn文件控制TCP连接的最大连接数目。

         已经成功建立的队列中如果有条目,内核就会唤醒用户进程将最新的给进程。

         如果正在等待client回复的队列满了,内核将无法再继续接收新的TCP SYNC连接请求,此时server将不回复(如果回复RST,用户端就会放弃。不回复其会重试,说不定一会就有了)。通常出现这种情况要么是设置的backlog不够大,要么是受到TCP flood攻击了。

int accept(int sockfd, struct sockaddr*cliaddr, socklen_t *addrlen)

         当客户端调用connect成功(三次握手成功),server端的服务器进程就可以得到对应的socket。但是socket是内核管理的,内核可以将服务进程唤醒,但是没有办法主动的把已经好的socket交给用户进程,需要用户进程主动发起,这就是accept系统调用。

         实际上,内核完全可以主动把已经连接的socket的放到用户进程预先制定的用户内存中,然后再唤醒用户进程。但是如此设计,用户进程调用listen醒来就需要检查为何醒来,还需要去指定的缓存去获得已经建立的socket,并且这块缓存一次缓存多少个合适呢?这一切都增加了复杂度。最终采用的设计是内核只负责唤醒在listen的进程,进程被唤醒后调用accept函数会去向内核尝试获得一个已经完成3次握手的socket。如此,所有的流程安排掌握在进程手中。还是那个原则,提供机制而不提供策略。

         这里要注意的一点是accept得到的返回值是建立了三次握手的socket,但是不是其在监听TCP连接的socket,对比一下的话就会发现两者的句柄是不相同的。listen的socket在用户没有进入listen时暂停,新产生的socket表示了一个已经建立的TCP连接。通过该连接,用户与server可以互相通信。由于一般情况下server不止服务一个用户,所以accept得到的socket一般会重新建立一个线程或者进程与用户进行通信。原server进程继续调用listen等待新的用户连接。

客户端

int connect(int sockfd, const structsockaddr* servaddr, socklen_t addrlen);

         listen是服务端等待3次握手的过程,connect则是客户端发起3次握手的过程。对于UDP客户端,则是选择了之后发送数据包的IP:端口,没有实际的网络操作。

         3次握手首先发送SYNC,对于客户端来说,就要等待回复的SYNC/ACK,因此这个等待与服务器的情况类似,很有可能其也收不到回复。在发送第一SYNC之后可能收到目的地址不可达的ICMP、可能收到RST回复也可能什么都收不到。对于不同的回复不同的操作系统可以有不同的反应。例如什么都收不到可以有超时重传,重传规定次数。

数据通信

TCP

UDP

Linux Socket连接模型

         阻塞、多进程、select、epoll。

         最基本的就是阻塞模型,进程阻塞listen,来了连接自己accept,自己接收并处理数据。由于其在处理数据的过程中不能继续listen,所以一次只能服务一个用户。

改进的,当accept到一个新的连接,就fork出一个新的线程(Linux下进程与线程本质是一样的),用新的线程去处理这个连接而自己继续listen。

         由于fork的成本比较高,所以改进可以预先生成一个线程池。里面有很多待服务的进程。这时可以设计为让主进程自己listen,也可以让各个线程一起listen,谁抢到谁服务,其他继续listen。如果内核的一个socket有了连接,会一次叫醒所有正在listen的进程(线程),如此的开销也不小。所以,一般会采用主线程统一listen。

         上述的3种模型一次都只可以listen一个socket,然而一个进程有时候需要同时listen多个socket,此时需要select(pselect0)和epoll(poll)。

IO多路复用

         select、pselect、poll、epoll都是IO多路复用。这里的IO不但包括网络通信,还包括与本机磁盘的通信(读写)。因此,使用这几个函数监听的描述符可能是socket在等待连接,也有可能是写入磁盘的操作等待完成。

         pselect只是select的POSIX函数接口版本,内容上没有太大差别。poll也和select流程和功能一样,一般网络应用都直接使用select了。但是select系列调用有致命的缺点,就是每次调用都需要把要监听的句柄集拷贝到内核,内核还需要完整的遍历所有句柄来查看哪个有新的动态。如此在要监听的socket很多时就会有问题。由于select在处理完成后还会继续调用,又触发一次拷贝和遍历,导致连接太频繁时,开销更大。而且select支持的句柄只有128个。

         epoll是专门用来改善select的弊端的。针对每次调用都要传递句柄集,epoll定义了epoll_create函数,在调用等待之前把句柄都一次性拷贝到内核,之后要修改的话调用epoll_ctrl。如此每次调用等待连接的函数epoll_wait时就不需要频繁拷贝。并且,epoll不是遍历查看句柄的状态,而是注册回调函数。当句柄状态发生变化的时候,就会调用对应的回调函数。由轮训到中断模型的变化无疑可以提高效率。

你可能感兴趣的:(linux,linux,socket,网络,kernel,内核)