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

系列文章传送门:html

  1. 网络协议 1 - 概述
  2. 网络协议 2 - IP 是怎么来,又是怎么没的?
  3. 网络协议 3 - 从物理层到 MAC 层
  4. 网络协议 4 - 交换机与 VLAN:办公室太复杂,我要回学校
  5. 网络协议 5 - ICMP 与 ping:投石问路的侦察兵
  6. 网络协议 6 - 路由协议:敢问路在何方?
  7. 网络协议 7 - UDP 协议:性善碰到城会玩
  8. 网络协议 8 - TCP 协议(上):性恶就要套路深
  9. 网络协议 9 - TCP协议(下):聪明反被聪明误

    前面一直在说各类协议,偏理论方面的知识,此次我们就来认识下基于 TCP 和 UDP 协议这些理论知识的 Socket 编程。node

    说 TCP 和 UDP 的时候,咱们是分红客户端和服务端来认识的,那在写 Socket 的时候,咱们也这样分。linux

    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 地址和端口号,而后开始发起三次握手。内核会给客户端分配一个临时的端口,一旦握手成功,服务端的 accept 就会返回另外一个 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 函数调用过程:

clipboard.png

服务器最大并发量

    了解了基本的 Socket 函数后,就能够写出一个网络交互的程序了。就像上面的过程同样,在创建链接后,进行一个 while 循环,客户端发了收,服务端收了发。

    很明显,这种一台服务器服务一个客户的方式和咱们的实际须要相差甚远。这就至关于老板成立了一个公司,只有本身一我的,本身亲自服务客户,只能干完一家再干下一家。这种方式确定赚不了钱,这时候,就要想,我最多能接多少项目呢?

    咱们能够先来算下理论最大值,也就是理论最大链接数。系统会用一个四元组来标识一个 TCP 链接:

{本机 IP,本机端口,对端 IP,对端端口}

    服务器一般固定监听某个本地端口,等待客户端链接请求。所以,上面四元组中,可变的项只有对端 IP 和对端端口,也就是客户端 IP 和客户端端口。不可贵出:

最大 TCP 链接数 = 客户端 IP 数 x 客户端端口数。

    对于 IPv4:

客户端最大 IP 数 = 2 的 32 次方

    对于端口数:

客户端最大端口数 = 2 的 16 次方

    所以:

最大 TCP 链接数 = 2 的 48 次方(估算值)

    固然,服务端最大并发 TCP 链接数远不能达到理论最大值。主要有如下缘由:

  1. 文件描述符限制。按照上面的原理,Socket 都是文件,因此首先要经过 ulimit 配置文件描述符的数目;
  2. 内存限制。按上面的数据结构,每一个 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 函数的方式,当某个文件描述符发生变化的时候,主动通知。

clipboard.png

    如上图所示,假设进程打开了 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 问题。

参考:

  1. The TCP/IP Guide;
  2. 百度百科 - Socket 词条;
  3. 刘超 - 趣谈网络协议系列课;
相关文章
相关标签/搜索