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

 系列文章传送门:

  1.     连接建立成功之后,双方开始通过 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 连接数远不能达到理论最大值。主要有以下原因:

    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 函数来监听文件描述符集合是否有变化,一旦有变化,就会依次查看每个文件描述符。那些发生

50000+
5万行代码练就真实本领
17年
创办于2008年老牌培训机构
1000+
合作企业
98%
就业率

联系我们

电话咨询

0532-85025005

扫码添加微信