网络协议趣谈基于TCP和UDP的套接字Socket编程
Posted sysu_lluozh
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了网络协议趣谈基于TCP和UDP的套接字Socket编程相关的知识,希望对你有一定的参考价值。
在TCP和UDP协议中主要分客户端和服务端,在写程序的时候也同样这样分
Socket这个名字很有意思,可以作插口或者插槽讲。虽然是软件程序,但可以想象为弄一根网线,一头插在客户端,一头插在服务端,然后进行通信。所以在通信之前,双方都要建立一个Socket
在建立Socket的时候,应该设置什么参数呢?Socket编程进行的是端到端的通信,往往意识不到中间经过多少局域网,多少路由器,因而能够设置的参数也只能是端到端协议之上网络层和传输层的
- 网络层
Socket函数需要指定到底是IPv4还是IPv6,分别对应设置为AF_INET
和AF_INET6
- 传输层
Socket函数需要指定到底是TCP还是UDP,TCP协议是基于数据流,所以设置为SOCK_STREAM
,而UDP是基于数据报,因而设置为SOCK_DGRAM
一、基于TCP协议的Socket程序函数调用过程
两端创建了Socket之后,接下来的过程中TCP和UDP稍有不同,接下来先来看TCP
1.1 服务端bind函数赋予IP和端口
TCP的服务端要先监听一个端口,一般是调用bind
函数给这个Socket赋予一个IP地址和端口。
- 为什么需要端口呢?
要知道,应用程序当一个网络包来的时候,内核要通过TCP头里面的这个端口来找到这个应用程序,把包给你
- 为什么要IP地址呢?
有时一台机器会有多个网卡,也就会有多个IP地址,可以选择监听所有的网卡,也可以选择监听一个网卡,这样,只有发给这个网卡的包,才会给你
1.2 服务端listen函数服务监听
当服务端有了IP和端口号,就可以调用listen
函数进行监听。在TCP的状态图里,有一个listen
状态,当调用这个函数之后服务端就进入了这个状态,这个时候客户端就可以发起连接
在内核中,为每个Socket维护两个队列:
-
一个是已经建立了连接的队列
这时候连接三次握手已经完毕,处于
established
状态 -
一个是还没有完全建立连接的队列
这个时候三次握手还没完成,处于
syn_rcvd
的状态
1.3 服务端accept函数处理完成的连接
接下来,服务端调用accept
函数,拿出一个已经完成的连接进行处理。如果还没有完成就要等着
1.4 客户端connect函数发起连接
在服务端等待的时候,客户端可以通过connect
函数发起连接
- 先在参数中指明要连接的IP地址和端口号
- 然后开始发起三次握手
内核会给客户端分配一个临时的端口
1.5 建立监听的Socket和传数据的Socket
一旦握手成功,服务端的accept就会返回另一个Socket。这是一个问到的知识点,就是监听的Socket和真正用来传数据的Socket是两个,一个叫作监听Socket,一个叫作已连接Socket
1.6 连接成功read和write读写数据
连接建立成功之后,双方开始通过read
和write
函数来读写数据,就像往一个文件流里面写东西一样
1.7 函数调用过程
下图就是基于TCP协议的Socket程序函数调用过程
1.8 TCP的Socket是一个文件流
说TCP的Socket就是一个文件流是非常准确的,因为Socket在Linux中就是以文件的形式存在的。除此之外,还存在文件描述符。写入和读出,也是通过文件描述符
在内核中,Socket是一个文件,那对应就有文件描述符。每一个进程都有一个数据结构task_struct,里面指向一个文件描述符数组,来列出这个进程打开的所有文件的文件描述符。文件描述符是一个整数,是这个数组的下标
这个数组中的内容是一个指针,指向内核中所有打开的文件的列表。既然是一个文件,就会有一个inode,只不过Socket对应的inode不像真正的文件系统一样保存在硬盘上,而是在内存中。在这个inode中,指向了Socket在内核中的Socket结构
在这个结构里面,主要的是两个队列,一个是发送队列,一个是接收队列。在这两个队列里保存的是一个缓存sk_buff。这个缓存里面能够看到完整的包的结构。看到这个,可以和前面的收发包的场景联系起来
整个数据结构如下图:
二、基于UDP协议的Socket程序函数调用过程
对于UDP来讲过程有些不一样
-
UDP是没有连接的
所以不需要三次握手,也就不需要调用
listen
和connect
,但是,UDP的交互仍然需要IP和端口号,因而也需要bind
-
UDP是没有维护连接状态的
因而不需要每对连接建立一组Socket,而是只要有一个Socket就能够和多个客户端通信
-
UDP没有连接状态
每次通信的时候都调用
sendto
和recvfrom
,都可以传入IP地址和端口
下图的内容就是基于UDP协议的Socket程序函数调用过程:
三、服务器如何接更多的项目
会了这几个基本的Socket函数之后,可以轻松地写出一个网络交互的程序。就像上面的过程一样,在建立连接后进行一个while循环,客户端发了收,服务端收了发
当然这只是万里长征的第一步,因为如果使用这种方法,基本上只能一对一沟通。如果一个服务器同时只能服务一个客户,肯定是不行的。这就相当于老板成立一个公司,只有自己一个人,自己亲自上来服务客户,只能干完了一家再干下一家,这样赚不来多少钱
3.1 建立连接数的理论最大值
那作为老板就要想了,最多能接多少项目呢?当然是越多越好
先来算一下理论值,也就是最大连接数,系统会用一个四元组来标识一个TCP连接
本机IP, 本机端口, 对端IP, 对端端口
服务器通常固定在某个本地端口上监听,等待客户端的连接请求
因此,服务端TCP连接四元组中只有对端IP,也就是客户端的IP和对端的端口,也即客户端的端口是可变的,因此,最大TCP连接数=客户端IP数×客户端端口数
对IPv4,客户端的IP数最多为2的32次方,客户端的端口数最多为2的16次方,也就是服务端单机最大TCP连接数,约为2的48次方
3.2 达不到理论上限的原因
当然,服务端最大并发TCP连接数远不能达到理论上限
- 主要是因为文件描述符限制
按照上面的原理Socket都是文件,所以首先要通过ulimit配置文件描述符的数目
- 另一个限制是内存
按上面的数据结构每个TCP连接都要占用一定内存,操作系统是有限的
3.3 提高连接数的方案
所以,作为老板,在资源有限的情况下,要想接更多的项目就需要降低每个项目消耗的资源数目
3.3.1 方式一:将项目外包给其他公司(多进程方式)
这就相当于你是一个代理,在那里监听来的请求
一旦建立一个连接就会有一个已连接Socket,这时候可以创建一个子进程,然后将基于已连接Socket的交互交给这个新的子进程来做
就像来了一个新的项目,但是项目不一定是自己做,可以再注册一家子公司并且招点人,然后把项目转包给这家子公司做,以后对接就交给这家子公司了,自己又可以去接新的项目
这里有一个问题是,如何创建子公司,并如何将项目移交给子公司呢?
在Linux下,创建子进程使用fork
函数
通过名字可以看出,这是在父进程的基础上完全拷贝一个子进程。在Linux内核中,会复制文件描述符的列表,也会复制内存空间,还会复制一条记录当前执行到了哪一行程序的进程
显然,复制的时候在调用fork
,复制完毕之后父进程和子进程都会记录当前刚刚执行完fork
。这两个进程刚复制完的时候几乎一模一样,只是根据fork
的返回值来区分到底是父进程,还是子进程。如果返回值是0则是子进程,如果返回值是其他的整数就是父进程
进程复制过程如下图:
因为复制了文件描述符列表,而文件描述符都是指向整个内核统一的打开文件列表的,因而父进程刚才因为accept
创建的已连接Socket也是一个文件描述符,同样也会被子进程获得
接下来,子进程就可以通过这个已连接Socket和客户端进行互通,当通信完毕之后就可以退出进程,那父进程如何知道子进程干完了项目要退出呢?因为fork
返回的时候,如果是整数就是父进程,这个整数就是子进程的ID,父进程可以通过这个ID查看子进程是否完成项目,是否需要退出
3.3.2 方式二:将项目转包给独立的项目组(多线程方式)
上面这种方式应该也能发现问题,如果每次接一个项目都申请一个新公司,然后干完了就注销掉这个公司,实在是太麻烦了。毕竟一个新公司要有新公司的资产,有新的办公家具,每次都买了再卖,不划算
于是应该可以想到使用线程的方式,相比于进程来讲这样要轻量级的多。如果创建进程相当于成立新公司购买新办公家具,而创建线程就相当于在同一个公司成立项目组。一个项目做完了,那这个项目组就可以解散,组成另外的项目组时办公家具可以共用
在Linux下,通过pthread_create
创建一个线程也是调用do_fork
。不同的是,虽然新的线程在task列表会新创建一项,但是很多资源例如文件描述符列表、进程空间,还是共享的,只不过多了一个引用而已
新的线程也可以通过已连接Socket处理请求,从而达到并发处理的目的
上面基于进程或者线程模型,其实还是有问题的。新到来一个TCP连接就需要分配一个进程或者线程,一台机器无法创建很多进程或者线程。有个C10K,它的意思是一台机器要维护1万个连接,需要创建1万个进程或者线程,那么操作系统是无法承受的。如果维持1亿用户在线需要10万台服务器,成本也太高
其实C10K问题就是,接项目接的太多了,如果每个项目都成立单独的项目组就要招聘10万人,肯定养不起,那怎么办呢?
3.3.3 方式三:一个项目组支撑多个项目(IO多路复用,一个线程维护多个Socket)
当然,一个项目组可以看多个项目了
这个时候,每个项目组都应该有个项目进度墙,将自己组看的项目列在那里,然后每天通过项目墙看每个项目的进度,一旦某个项目有了进展, 就派人去盯一下
由于Socket是文件描述符,因而某个线程盯的所有的Socket,都放在一个文件描述符集合 fd_set
中,这就是项目进度墙,然后调用select
函数来监听文件描述符集合是否有变化。一旦 有变化,就会依次查看每个文件描述符。那些发生变化的文件描述符在fd_set
对应的位都设为 1,表示Socket可读或者可写,从而可以进行读写操作,然后再调用select
,接着盯着下一轮 的变化
3.3.4 方式四:一个项目组支撑多个项目(IO多路复用,从“派人盯着”到“有事通知”)
上面select函数还是有问题的,因为每次Socket所在的文件描述符集合中有Socket发生变化时,都需要通过轮询的方式,也就是需要将全部项目都过一遍的方式来查看进度,这大大影响了一个项目组能够支撑的最大的项目数量。因而使用select
,能够同时盯的项目数量由FD_SETSIZE
限制
如果改成事件通知的方式,情况就会好很多,项目组不需要通过轮询挨个盯着这些项目,而是当项目进度发生变化时主动通知项目组,然后项目组再根据项目进展情况做相应的操作
能完成这件事情的函数叫epoll
,它在内核中的实现不是通过轮询的方式,而是通过注册callback
函数的方式,当某个文件描述符发送变化的时候,就会主动通知
如图所示,假设进程打开了Socket m, n, x等多个文件描述符,现在需要通过epoll
来监听是否 这些Socket都有事件发生。其中epoll_create
创建一个epoll
对象,也是一个文件,也对应一 个文件描述符,同样也对应着打开文件列表中的一项。在这项里面有一个红黑树,在红黑树里要保存这个epoll
要监听的所有Socket
当epoll_ctl
添加一个Socket的时候,其实是加入这个红黑树,同时红黑树里面的节点指向一个结构,将这个结构挂在被监听的Socket的事件列表中。当一个Socket来了一个事件的时候,可以从这个列表中得到epoll
对象,并调用callback
通知它
这种通知方式使得监听的Socket数据增加的时候,效率不会大幅度降低,能够支持同时监听的 Socket的数目也非常多。上限就为系统定义的、进程打开的最大文件描述符个数。因而,epoll
被称为解决C10K问题的利器
四、小结
关于套接字Socket编程的知识点总结一下:
- 要记住TCP和UDP的Socket的编程中,客户端和服务端都需要调用哪些函数
- 一个能够支撑大量连接的高并发服务端不容易,需要多进程、多线程,而epoll机制能解决C10K问题
以上是关于网络协议趣谈基于TCP和UDP的套接字Socket编程的主要内容,如果未能解决你的问题,请参考以下文章