系列文章传送门:
网络协议 1 - 概述
网络协议 2 - IP 是怎么来, 又是怎么没的?
网络协议 3 - 从物理层到 Mac 层
网络协议 4 - 交换机与 VLAN: 办公室太复杂, 我要回学校
网络协议 5 - ICMP 与 ping: 投石问路的侦察兵
网络协议 6 - 路由协议: 敢问路在何方?
网络协议 7 - UDP 协议: 性善碰到城会玩
网络协议 8 - TCP 协议 (上): 性恶就要套路深
网络协议 9 - TCP 协议 (下): 聪明反被聪明误
前面一直在说各种协议, 偏理论方面的知识, 这次咱们就来认识下基于 TCP 和 UDP 协议这些理论知识的 Socket 编程.
说 TCP 和 UDP 的时候, 我们是分成客户端和服务端来认识的, 那在写 Socket 的时候, 我们也这样分.
Socket 这个名字很有意思, 可以作插口或者插槽讲. 我们写程序时, 就可以将 Socket 想象为, 一头插在客户端, 一头插在服务端, 然后进行通信.
在建立 Socket 的时候, 应该设置什么参数呢? Socket 编程进行的是端到端的通信, 往往意识不到中间经过多少局域网, 多少路由器, 因而能够设置的参数, 也只能是端到端协议之上网络层和传输层的.
对于网络层和传输层, 有以下参数需要设置:
IP 协议: IPv4 对应 AF_INEF,IPv6 对应 AF_INET6;
传输层协议: TCP 与 UDP.TCP 协议基于数据流, 其对应值是 SOCKET_STREAM, 而 UDP 是基于数据报的, 其对应值是 SOCKET_DGRAM.
两端创建了 Socket 之后, 而后面的过程中, TCP 和 UDP 稍有不同, 我们先来看看 TCP.
基于 TCP 协议的 Socket
对于 TCP 创建 Socket 的过程, 有以下几步走:
1)TCP 调用 bind 函数赋予 Socket IP 地址和端口.
为什么需要 IP 地址? 还记得吗? 咱们之前了解过, 一台机器会有多个网卡, 而每个网卡就有一个 IP 地址, 我们可以选择监听所有的网卡, 也可以选择监听一个网卡, 只有, 发给指定网卡的包才会发给你.
为什么需要端口? 要知道, 咱们写的是一个应用程序, 当一个网络包来的时候, 内核就是要通过 TCP 里面的端口号来找到对应的应用程序, 把包给你.
2) 调用 listen 函数监听端口. 在 TCP 的状态图了, 有一个 listen 状态, 当调用这个函数之后, 服务端就进入了这个状态, 这个时候客户端就可以发起连接了.
在内核中, 为每个 Socket 维护两个队列. 一个是已经建立了连接的队列, 这里面的连接已经完成三次握手, 处于 established 状态; 另一个是还没有完全建立连接的队列, 这里面的连接还没有完成三次握手, 处于 syn_rcvd 状态.
3) 服务端调用 accept 函数. 这时候服务端会拿出一个已经完成的连接进行处理, 如果还没有已经完成的连接, 就要等着.
在服务端等待的时候, 客户端可以通过 connect 函数发起连接. 客户端先在参数中指明要连接的 IP 地址和端口号, 然后开始发起三次握手. 内核会给客户端分配一个临时的端口, 一旦握手成功, 服务端的 accep 就会返回另一个 Socket.
注意, 从上面的过程中可以看出, 监听的 Socket 和真正用来传数据的 Socket 是不同的两个. 一个叫做监听 Socket, 一个叫做已连接 Socket.
下图就是基于 TCP 协议的 Socket 函数调用过程:
连接建立成功之后, 双方开始通过 read 和 write 函数来读写数据, 就像往一个文件流里写东西一样.
这里说 TCP 的 Socket 是一个文件流, 是非常准确的. 因为 Socket 在 Linux 中就是以文件的形式存在的. 除此之外, 还存在文件描述符. 写入和读出, 也是通过文件描述符.
每一个进程都有一个数据结构 task_struct, 里面指向一个文件描述符数组, 来列出这个进程打开的所有文件的文件描述符. 文件描述符是一个整数索引值, 是这个数组的下标.
这个数组中的内容是一个指针, 指向内核中所有打开的文件列表. 而每个文件也会有一个 inode(索引节点).
对于 Socke 而言, 它是一个文件, 也就有对于的文件描述符. 与真正的文件系统不一样的是, Socket 对于的 inode 并不是保存在硬盘上, 而是在内存中. 在这个 inode 中, 指向了 Socket 在内核中的 Socket 结构.
在这个机构里面, 主要有两个队列. 一个发送队列, 一个接收队列. 这两个队列里面, 保存的是一个缓存 sk_buff. 这个缓存里能够看到完整的包结构. 说到这里, 你应该就会发现, 数据结构以及和前面了解的收发包的场景联系起来了.
上面整个过程说起来稍显混乱, 可对比下图加深理解.
基于 UDP 协议的 Socket
基于 UDP 的 Socket 编程过程和 TCP 有些不同. UDP 是没有连接状态的, 所以不需要三次握手, 也就不需要调用 listen 和 connect. 没有连接状态, 也就不需要维护连接状态, 因而不需要对每个连接建立一组 Socket, 只要建立一组 Socket, 就能和多个客户端通信. 也正是因为没有连接状态, 每次通信的时候, 都可以调用 sendto 和 recvfrom 传入 IP 地址和端口.
下图是基于 UDP 的 Socket 函数调用过程:
服务器最大并发量
了解了基本的 Socket 函数后, 就可以写出一个网络交互的程序了. 就像上面的过程一样, 在建立连接后, 进行一个 while 循环, 客户端发了收, 服务端收了发.
很明显, 这种一台服务器服务一个客户的方式和我们的实际需要相差甚远. 这就相当于老板成立了一个公司, 只有自己一个人, 自己亲自服务客户, 只能干完一家再干下一家. 这种方式肯定赚不了钱, 这时候, 就要想, 我最多能接多少项目呢?
我们可以先来算下理论最大值, 也就是理论最大连接数. 系统会用一个四元组来标识一个 TCP 连接:
{本机 IP, 本机端口, 对端 IP, 对端端口}
服务器通常固定监听某个本地端口, 等待客户端连接请求. 因此, 上面四元组中, 可变的项只有对端 IP 和对端端口, 也就是客户端 IP 和客户端端口. 不难得出:
最大 TCP 连接数 = 客户端 IP 数 x 客户端端口数.
对于 IPv4:
客户端最大 IP 数 = 2 的 32 次方
对于端口数:
客户端最大端口数 = 2 的 16 次方
因此:
最大 TCP 连接数 = 2 的 48 次方 (估算值)
当然, 服务端最大并发 TCP 连接数远不能达到理论最大值. 主要有以下原因:
文件描述符限制. 按照上面的原理, Socket 都是文件, 所以首先要通过 ulimit 配置文件描述符的数目;
内存限制. 按上面的数据结构, 每个 TCP 连接都要占用一定的内存, 而系统内存是有限的.
所以, 作为老板, 在资源有限的情况下, 要想接更多的项目, 赚更多的钱, 就要降低每个项目消耗的资源数目.
本着这个原则, 我们可以找到以下几种方式来最可能的降低消耗项目消耗资源.
1) 将项目外包给其他公司 (多进程方式)
这就相当于你是一个代理, 监听来的请求, 一旦建立一个连接, 就会有一个已连接的 Socket, 这时候你可以创建一个紫禁城, 然后将基于已连接的 Socket 交互交给这个新的子进程来做. 就像来了一个新项目, 你可以注册一家子公司, 招人, 然后把项目转包给这就公司做, 这样你就又可以去接新的项目了.
这里有个问题是, 如何创建子公司, 并将项目移交给子公司?
在 Linux 下, 创建子进程使用 fork 函数. 通过名字可以看出, 这是在父进程的基础上完全拷贝一个子进程. 在 Linux 内核中, 会复制文件描述符的列表, 也会复制内存空间, 还会复制一条记录当前执行到了哪一行程序的进程.
这样, 复制完成后, 父进程和子进程都会记录当前刚刚执行完 fork. 这两个进程刚复制完的时候, 几乎一模一样, 只是根据 fork 的返回值来区分是父进程还是子进程. 如果返回值是 0, 则是子进程, 如果返回值是其他的整数, 就是父进程, 这里返回的整数, 就是子进程的 ID.
进程复制过程如下图:
因为复制了文件描述符列表, 而文件描述符都是指向整个内核统一的打开文件列表的. 因此父进程刚才因为 accept 创建的已连接 Socket 也是一个文件描述符, 同样也会被子进程获得.
接下来, 子进程就可以通过这个已连接 Socket 和客户端进行通信了. 当通信完成后, 就可以退出进程. 那父进程如何知道子进程干完了项目要退出呢? 父进程中 fork 函数返回的整数就是子进程的 ID, 父进程可以通过这个 ID 查看子进程是否完成项目, 是否需要退出.
2) 将项目转包给独立的项目组 (多线程方式)
上面这种方式你应该能发现问题, 如果每接一个项目, 都申请一个新公司, 然后干完了, 就注销掉, 实在是太麻烦了. 而且新公司要有新公司的资产, 办公家具, 每次都买了再卖, 不划算.
这时候, 我们应该已经想到了线程. 相比于进程来讲, 线程更加轻量级. 如果创建进程相当于成立新公司, 而创建线程, 就相当于在同一个公司成立新的项目组. 一个项目做完了, 就解散项目组, 成立新的项目组, 办公家具还可以共用.
在 Linux 下, 通过 pthread_create 创建一个线程, 也是调用 do_fork. 不同的是, 虽然新的线程在 task 列表会新创建一项, 但是很多资源, 例如文件描述符列表, 进程空间, 这些还是共享的, 只不过多了一个引用而已.
下图是线程复制过程:
新的线程也可以通过已连接 Socket 处理请求, 从而达到并发处理的目的.
上面两种方式, 无论是基于进程还是线程模型的, 其实还是有问题的. 新到来一个 TCP 连接, 就需要分配一个进程或者线程. 一台机器能创建的进程和线程数是有限的, 并不能很好的发挥服务器的性能. 著名的 C10K 问题, 就是说一台机器如何维护 1 万了连接. 按我们上面的方式, 系统就要创建 1 万个进程或者线程, 这是操作系统无法承受的.
那既然一个线程负责一个 TCP 连接不行, 能不能一个进程或线程负责多个 TCP 连接呢? 这就引出了下面两种方式.
3) 一个项目组支撑多个项目 (IO 多路复用, 一个线程维护多个 Socket)
当一个项目组负责多个项目时, 就要有个项目进度墙来把控每个项目的进度, 除此之外, 还得有个人专门盯着进度墙.
上面说过, Socket 是文件描述符, 因此某个线程盯的所有的 Socket, 都放在一个文件描述符集合 fd_set 中, 这就是项目进度墙. 然后调用 select 函数来监听文件描述符集合是否有变化, 一旦有变化, 就会依次查看每个文件描述符. 那些发生变化的文件描述符在 fd_set 对应的位都设为 1, 表示 Socket 可读或者可写, 从而可以进行读写操作, 然后再调用 select, 接着盯着下一轮的变化.
4) 一个项目组支撑多个项目 (IO 多路复用, 从 "派人盯着" 到 "有事通知")
上面 select 函数还是有问题的, 因为每次 Socket 所在的文件描述符集合中有发生变化的时候, 都需要通过轮询的方式将所有的 Socket 查看一遍, 这大大影响了一个进程或者线程能够支撑的最大连接数量. 使用 select, 能够同时监听的数量由 FD_SETSIZE 限制.
如果改成事件通知的方式, 情况就会好很多. 项目组不需要通过轮询挨个盯着所有项目, 而是当项目进度发生变化的时候, 主动通知项目组, 然后项目组再根据项目进展情况做相应的操作.
而 epoll 函数就能完成事件通知. 它在内核中的实现不是通过轮询的方式, 而是通过注册 callback 函数的方式, 当某个文件描述符发生变化的时候, 主动通知.
如上图所示, 假设进程打开了 Socket m,n,x 等多个文件描述符, 现在需要通过 epoll 来监听这些 Socket 是否有事件发生. 其中 epoll_create 创建一个 epoll 对象, 也是一个文件, 对应一个文件描述符, 同样也对应着打开文件列表中的一项. 在这项里面有一个红黑树, 在红黑树里, 要保存这个 epoll 监听的所有的 Socket.
当 epoll_ctl 添加一个 Scoket 的时候, 其实就是加入这个红黑树中. 同时, 红黑树里面的节点指向一个结构, 将这个结构挂在被监听的 Socket 的事件列表中. 当一个 Socket 发生某个事件时, 可以从这个列表中得到 epoll 对象, 并调用 call_back 通知它.
这种事件通知的方式使得监听的 Socket 数量增加的同时, 效率也不会大幅度降低. 因此, 能够同时监听的 Socket 的数量就非常的多了. 上限为系统定义的, 进程打开的最大文件描述符个数. 因而, epoll 被称为解决 C10K 问题的利器.
小结
牢记基于 TCP 和 UDP 的 Socket 编程中, 客户端和服务端需要调用的函数;
epoll 机制能够解决 C10K 问题.
参考:
The TCP/IP Guide;
百度百科 - Socket 词条;
刘超 - 趣谈网络协议系列课;
来源: https://www.cnblogs.com/BeiGuo-FengGuang/p/10069256.html