客户端和服务端在进行通信前,双方需要建立一个Socket
。
Socket
编程进行的是端到端的通信,往往意识不到中间经过多少局域网,多少路由器,因而能够设置的参数,也只能是端到端协议之上网络层和传输层的。
在网络层,Socket
函数需要指定到底是IPv4
还是IPv6
,分别对应设置为 AF_INET
和 AF_INET6
。TCP
协议是基于数据流的,所以设置为 SOCK_STREAM
,而 UDP
是基于数据报的,因而设置为 SOCK_DGRAM
。
基于 TCP 协议的 Socket 程序函数调用过程
TCP
的服务端要先监听一个端口,一般是先调用 bind 函数
,给这个 Socket 赋予一个 IP 地址
和端口
。当一个网络包来的时候,内核要通过 TCP 头里面的这个端口,来找到应用程序并传递包。有时候一台机器会有多个网卡,就会有多个 IP 地址,可以选择监听所有的网卡,也可以选择监听一个网卡,这样只有发给这个网卡的包,才会转发过来。
当服务端有了 IP 和端口号,就可以调用listen 函数
进行监听。在 TCP 的状态图里面,有一个listen
状态,当调用这个函数之后,服务端就进入了这个状态,这个时候客户端就可以发起连接了。
内核中,为每个 Socket 维护两个队列。一个是已经建立了连接的队列,这时候连接三次握手已经完毕,处于 established
状态;一个是还没有完全建立连接的队列,这个时候三次握手还没完成,处于 syn_rcvd
的状态。
接下来,服务端调用 accept 函数
,拿出一个已经完成的连接进行处理。如果还没有完成,就继续等待。
服务端等待的时候,客户端可以通过 connect 函数
发起连接。先在参数中指明要连接的 IP 地址
和端口号
,然后开始发起三次握手
。内核会给客户端分配一个临时的端口。一旦握手成功,服务端的 accept 就会返回另一个 Socket。监听的 Socket 和真正用来传数据的 Socket 是两个,一个叫作监听 Socket,一个叫作已连接 Socket。
连接建立成功之后,双方开始通过read
和write 函数
来读写数据,就像往一个文件流里面写东西一样。
Socket 在 Linux 中就是以文件的形式存在的。除此之外,还存在文件描述符。写入和读出,也是通过文件描述符。在内核中,Socket 是一个文件,那对应就有文件描述符。每一个进程都有一个数据结构 task_struct
,里面指向一个文件描述符数组,来列出这个进程打开的所有文件的文件描述符。文件描述符是一个整数,是这个数组的下标。这个数组中的内容是一个指针,指向内核中所有打开的文件的列表。既然是一个文件,就会有一个 inode,只不过 Socket 对应的 inode 不像真正的文件系统一样,保存在硬盘上的,而是在内存中的。在这个 inode 中,指向了 Socket 在内核中的 Socket 结构。在这个结构里面,主要的是两个队列,一个是发送队列,一个是接收队列。在这两个队列里面保存的是一个缓存 sk_buff。
基于UDP协议的Socket程序函数调用过程
UDP 无连接,不需要三次握手,也就不需要调用 listen
和 connect
,但是,UDP 的交互仍然需要 IP
和端口号
,因而也需要 bind
函数。UDP 是没有维护连接状态的,因而不需要每对连接建立一组 Socket,而是只要有一个 Socket,就能够和多个客户端通信。也正是因为没有连接状态,每次通信的时候,都调用 sendto
和 recvfrom
,都可以传入 IP 地址和端口。
服务器与多个客户端
系统会用一个四元组来标识一个 TCP 连接。
{本机IP, 本机端口, 对端IP, 对端端口}
服务器通常固定在某个本地端口上监听,等待客户端的连接请求。因此,服务端端 TCP 连接四元组中只有对端 IP, 也就是客户端的 IP 和对端的端口,也即客户端的端口是可变的。最大 TCP 连接数 = 客户端 IP 数×客户端端口数。对 IPv4,客户端的 IP 数最多为 2 的 32 次方,客户端的端口数最多为 2 的 16 次方,也就是服务端单机最大 TCP 连接数,约为 2 的 48 次方。当然,服务端最大并发 TCP 连接数远不能达到理论上限。首先主要是文件描述符限制,按照上面的原理,Socket 都是文件,所以首先要通过 ulimit 配置文件描述符的数目;另一个限制是内存,按上面的数据结构,每个 TCP 连接都要占用一定内存,操作系统是有限的。
解决方案一:多进程方式
相当于你是一个代理,在那里监听来的请求。一旦建立了一个连接,就会有一个已连接 Socket,这时候你可以创建一个子进程,然后将基于已连接 Socket 的交互交给这个新的子进程来做。
在 Linux 下,创建子进程使用 fork
函数。通过名字可以看出,这是在父进程的基础上完全拷贝一个子进程。在 Linux 内核中,会复制文件描述符的列表,也会复制内存空间,还会复制一条记录当前执行到了哪一行程序的进程。显然,复制的时候在调用fork
,复制完毕之后,父进程和子进程都会记录当前刚刚执行完 fork
。这两个进程刚复制完的时候,几乎一模一样,只是根据 fork
的返回值来区分到底是父进程,还是子进程。如果返回值是 0,则是子进程;如果返回值是其他的整数,就是父进程。
解决方案一:多线程方式
相比于进程来讲,线程要轻量级的多。如果创建进程相当于成立新公司,购买新办公家具,而创建线程,就相当于在同一个公司成立项目组。一个项目做完了,那这个项目组就可以解散,组成另外的项目组,办公家具可以共用。
在 Linux 下,通过pthread_create
创建一个线程,也是调用do_fork
。不同的是,虽然新的线程在 task 列表会新创建一项,但是很多资源,例如文件描述符列表、进程空间,还是共享的,只不过多了一个引用而已。
解决方案三:IO多路复用,一个线程维护多个Socket
由于 Socket 是文件描述符,因而某个线程盯的所有的 Socket,都放在一个文件描述符集合 fd_set 中,这就是项目进度墙,然后调用 select 函数来监听文件描述符集合是否有变化。一旦有变化,就会依次查看每个文件描述符。那些发生变化的文件描述符在 fd_set 对应的位都设为 1,表示 Socket 可读或者可写,从而可以进行读写操作,然后再调用 select,接着盯着下一轮的变化。
解决方案四:IO多路复用,一个线程维护多个Socket
通过epoll
函数改成事件通知的方式,情况就会好很多,项目组不需要通过轮询挨个盯着这些项目,而是当项目进度发生变化的时候,主动通知项目组,然后项目组再根据项目进展情况做相应的操作。它在内核中的实现不是通过轮询的方式,而是通过注册 callback
函数的方式,当某个文件描述符发送变化的时候,就会主动通知。
假设进程打开了 Socket m, n, x 等多个文件描述符,现在需要通过 epoll
来监听是否这些 Socket 都有事件发生。其中epoll_create
创建一个 epoll
对象,也是一个文件,也对应一个文件描述符,同样也对应着打开文件列表中的一项。在这项里面有一个红黑树,在红黑树里,要保存这个epoll
要监听的所有 Socket
。
当epoll_ctl
添加一个 Socket 的时候,其实是加入这个红黑树,同时红黑树里面的节点指向一个结构,将这个结构挂在被监听的 Socket 的事件列表中。当一个 Socket 来了一个事件的时候,可以从这个列表中得到 epoll 对象,并调用 call back
通知它。这种通知方式使得监听的 Socket 数据增加的时候,效率不会大幅度降低,能够同时监听的 Socket 的数目也非常的多了。
小结
- 需要记住 TCP 和 UDP 的 Socket 的编程中,客户端和服务端都需要调用哪些函数
- 写一个能够支撑大量连接的高并发的服务端不容易,需要多进程、多线程,而 epoll 机制能解决 C10K 问题:一台机器要维护 1 万个连接,就要创建 1 万个进程或者线程