当前位置: 首页 > Linux

网络协议10-Socket编程:实践是检验真理的唯一标准

时间:2023-04-06 20:43:14 Linux

系列文章传送门:网络协议1-网络协议概述2-IP是怎么来的,为什么消失了?网络协议3-从物理层到MAC层网络协议4-交换机和VLAN:办公室太复杂,我要回学校了网络协议5-ICMP和ping:问路的童子军网络协议6-路由协议:敢问路在何方?网络协议7-UDP协议:善良的人可以在城市里玩网络协议8-TCP协议(上):邪恶的自然必须套路深层网络协议9-TCP协议(下):聪明被聪明人冤枉了我一直在前面讲了Protocol的各种东西,偏理论知识,这次我们就基于TCP和UDP协议的理论知识来认识一下Socket编程。当我们谈到TCP和UDP时,我们通过将它们分为客户端和服务器来认识它们。在写Socket的时候,我们也是按照同样的方式来划分的。Socket这个名字很有意思,可以当做socket也可以当slot使用。我们写程序的时候,可以把Socket想成是一端插客户端,另一端插服务器,然后进行通信。创建Socket时,应该设置哪些参数?socket编程是为了端到端通信,往往不知道中间经过了多少个局域网和路由器,所以可以设置的参数只能是端到端以上的网络层和传输层结束协议。对于网络层和传输层,需要设置如下参数:IP协议:IPv4对应AF_INEF,IPv6对应AF_INET6;传输层协议:TCP和UDP。TCP协议基于数据流,其对应值为SOCKET_STREAM,而UDP基于数据报,其对应值为SOCKET_DGRAM。两端都创建好Socket之后,在接下来的过程中,TCP和UDP略有不同,我们先来看看TCP。基于TCP协议的SocketTCP创建Socket的过程有以下几个步骤:1)TCP调用bind函数为Socket分配IP地址和端口。为什么需要IP地址?你是否记得?之前我们了解到,一台机器有多个网卡,每个网卡都有一个IP地址。我们可以选择监控所有网卡,也可以选择监控一个网卡。只有发送到指定网卡的数据包才会被发送。为你。为什么需要端口?你知道,我们写的是一个应用程序。当一个网络数据包到来时,内核使用TCP中的端口号找到相应的应用程序并将数据包交给你。2)调用listen函数监听端口。在TCP的状态图中,有一个监听状态。调用该函数后,服务端进入该状态,客户端此时可以发起连接。在内核中,为每个Socket维护了两个队列。一个是已经建立连接的队列,里面的连接已经完成三次握手,处于已建立状态;另一个是还没有完全建立连接的队列,里面的连接还没有完成三次握手,处于syn_rcvd状态。3)服务端调用accept函数。这个时候服务器会取出一个完成的连接进行处理。如果没有完成连接,它将等待。在服务器等待期间,客户端可以通过connect函数发起连接。客户端首先在参数中指定要连接的IP地址和端口号,然后发起三次握手。内核会为客户端分配一个临时端口。一旦握手成功,服务器端的accept会返回另一个Socket。注意,从上面的过程我们可以看出,监听的Socket和真正用来传输数据的Socket是不一样的。一种叫做监听Socket,另一种叫做连接Socket。下图是基于TCP协议的Socket函数调用流程:连接建立成功后,双方开始通过读写函数读写数据,就像在文件流中写东西一样。说TCPSocket是一个文件流是非常准确的。因为Socket在linux中是以文件的形式存在的。除此之外,还有文件描述符。写入和读取也是通过文件描述符。每个进程都有一个数据结构task_struct,它指向一个文件描述符数组,用来列出本进程打开的所有文件的文件描述符。文件描述符是一个整数索引值,是这个数组的下标。该数组中的内容是指向内核中所有打开文件的列表的指针。而且每个文件也会有一个inode(索引节点)。对于Socket来说,就是一个文件,有对应的文件描述符。与真正的文件系统不同,Socket的inode并不保存在硬盘上,而是保存在内存中。在这个inode中,指向了内核中Socket的Socket结构。在这个组织中,主要有两个队列。一个发送队列和一个接收队列。在这两个队列中,保存了一个缓存sk_buff。在此缓存中可以看到完整的包结构。说到这里大家应该会发现,这个数据结构和我们前面学的收发包的场景是连在一起的。上面的整个过程有点绕,不过大家可以和下图对比一下,加深理解。UDP-basedSocketUDP-basedSocket的编程过程与TCP有些不同。UDP没有连接状态,所以不需要三次握手,也不需要调用listen和connect。没有连接状态,也不需要维护连接状态,所以不需要为每个连接都建立一套Sockets。只要建立了一套Sockets,就可以与多个客户端通信。正是因为没有连接状态,所以每次通信都可以调用sendto和recvfrom传入IP地址和端口。下图是基于UDP的Socket函数的调用过程:服务端的最大并发了解了基本的Socket函数后,就可以编写网络交互的程序了。就像上面的过程一样,建立连接后,执行一个while循环,客户端发送接收,服务端接收发送。显然,这种用一台服务器为一个客户端服务的方式与我们的实际需求相去甚远。这就相当于老板成立了公司,只有他自己一个人服务客户,只能做完一家公司再去下一家。这种方法肯定赚不到钱。这时候你就得想,我最多能接多少个项目?我们可以先计算出理论最大值,即理论最大连接数。系统会用四元组来标识一个TCP连接:{本地IP,本地端口,对端IP,对端端口}服务端通常会监听本地某个端口,等待客户端的连接请求。所以,在上面的四元组中,唯一的可变项就是peerIP和peerport,也就是clientIP和clientport。不难得出:TCP最大连接数=客户端IP数×客户端端口数。对于IPv4:最大客户端IP数=2的32次方对于端口号:最大客户端端口数=2的16次方因此:TCP最大连接数=2的48次方(估计值)当然,服务器的maximumTCP并发连接数远未达到理论最大值。主要有以下几个原因:文件描述符限制。根据以上原则,Sockets都是文件,所以首先要通过ulimit配置文件描述符个数;内存限制。根据上面的数据结构,每个TCP连接都会占用一定的内存,而系统内存是有限的。所以,作为老板,在资源有限的情况下,想要接更多的项目,赚更多的钱,就必须减少每个项目消耗的资源数量。基于这个原则,我们可以找到以下方法来尽可能减少消费项的资源消耗。1)将项目外包给其他公司(多进程方式)这相当于你做一个代理,监听传入的请求,一旦建立连接,就会有一个连接的Socket,这时候你可以创建一个故宫,然后将连接的Socket交互交给这个新的子进程。就像一个新的项目来了,你可以注册一个子公司,招人,然后把项目分包给这家公司,这样你就可以再接新的项目了。这里有一个问题,如何创建子公司并将项目交给子公司?在Linux下,使用fork函数创建一个子进程。从名字就可以看出,这是在父进程的基础上完整复制了一个子进程。在Linux内核中,文件描述符列表被复制,内存空间也被复制,记录当前执行了哪一行程序的进程被复制。这样,复制完成后,父进程和子进程都会记录刚刚执行过fork。两个进程刚刚复制时,几乎完全一样,只是根据fork的返回值来区分是父进程还是子进程。如果返回值为0,则为子进程。如果返回值为其他整数,则为父进程。这里返回的整数是子进程的ID。进程复制过程如下:因为文件描述符列表被复制,文件描述符都指向整个内核统一的打开文件列表。所以,父进程仅仅因为accept而创建的connectedSocket也是一个文件描述符,它也会被子进程获取到。接下来子进程就可以通过这个连接的Socket与客户端进行通信了。当通信完成时,进程可以退出。父进程怎么知道子进程做完项目就退出呢?父进程中fork函数返回的整数就是子进程的ID。父进程可以通过这个ID来检查子进程是否完成了项目,是否需要退出。2)将项目分包给一个独立的项目组(多线程方式)上面的方法应该能发现问题。如果每次接一个项目就去申请一家新公司,做完了再注销,上来就太麻烦了。而且,新公司需要新公司的资产和办公家具。每次买卖都不划算。此时,我们应该想到了线程。线程比进程更轻量级。如果说创建一个流程相当于建立一个新的公司,那么创建一个线程就相当于在同一个公司建立一个新的项目组。一个项目完成后,解散项目组,成立新的项目组。办公家具也可以共用。在Linux下,通过pthread_create创建线程也是调用do_fork。不同的是,虽然一个新的线程会在任务列表中创建一个新的项目,但是文件描述符列表和进程空间等很多资源仍然是共享的,只是多了一个引用而已。下图是线程复制过程:新线程也可以通过连接的Socket处理请求,从而达到并发处理的目的。以上两种方法,不管是基于进程还是线程模型,其实还是有问题的。当一个新的TCP连接到达时,需要分配一个进程或线程。一台机器上可以创建的进程数和线程数是有限的,不能充分发挥服务器的性能。著名的C10K问题是一台机器如何维护10,000个连接。按照我们上面的方法,系统会创建10000个进程或者线程,这是操作系统无法承受的。既然一个线程不负责一个TCP连接,那么一个进程或线程是否可以负责多个TCP连接呢?这导致以下两种方法。3)一个项目组支持多个项目(IO多路复用,一个线程维护多个Socket)当一个项目组负责多个项目时,必须有一个项目进度墙来控制每个项目的进度,除此之外,必须有有人专注于盯着进度墙。上面说了Socket是一个文件描述符,所以某个线程star的所有Socket都放在一个文件描述符集合fd_set中,也就是项目进度墙。然后调用select函数监听文件描述符集是否发生变化。一旦有变化,将依次检查每个文件描述符。那些发生变化的文件描述符在fd_set的相应位被设置为1,表示该Socket是可读或可写的,这样就可以进行读写操作,然后调用select,然后盯着下一轮的变化.4)一个项目组支持多个项目(IO复用,从“派人看”到“通知某事”)Socket位于变化时,需要通过轮询的方式检查所有的Socket,这极大地影响了一个进程或线程所能支持的最大连接数。使用select,并发侦听器的数量受FD_SETSIZE限制。如果改为事件通知的方式,情况会好很多。项目组不需要对所有项目进行一一轮询,而是当项目进度发生变化时,会主动通知项目组,然后项目组根据项目进度采取相应的行动。epoll函数可以完成事件通知。它在内核中的实现不是通过轮询,而是通过注册一个回调函数,当一个文件描述符发生变化时,回调函数会主动通知。如上图所示,假设进程打开了多个Socketm、n、x等文件描述符,现在需要使用epoll来监听这些Socket上是否有事件发生。其中epoll_create创建一个epoll对象,它也是一个文件,对应一个文件描述符,也对应打开文件列表中的一个item。此项中有一棵红黑树。在红黑树中,本次epoll监听的所有Sockets都要保存。当epoll_ctl添加一个Scoket时,实际上是添加到红黑树中。同时,红黑树中的节点指向一个结构体,并将这个结构体挂在被监控的Socket的事件列表中。当一个Socket发生事件时,可以从这个列表中获取epoll对象,调用call_back通知它。这种事件通知的方式增加了监听Sockets的数量,并没有大大降低效率。所以同时可以监听的Socket数量是非常大的。上限由系统和进程打开的文件描述符的最大数量定义。因此epoll被誉为解决C10K问题的利器。小结牢记基于TCP和UDP的Socket编程中客户端和服务端需要调用的函数;epoll机制可以解决C10K问题。参考:TCP/IP指南;百度百科——Socket入门;刘超-网络协议趣谈系列;