系列文章传送门:html
前面一直在说各类协议,偏理论方面的知识,此次我们就来认识下基于 TCP 和 UDP 协议这些理论知识的 Socket 编程。node
说 TCP 和 UDP 的时候,咱们是分红客户端和服务端来认识的,那在写 Socket 的时候,咱们也这样分。linux
Socket 这个名字颇有意思,能够做插口或者插槽讲。咱们写程序时,就能够将 Socket 想象为,一头插在客户端,一头插在服务端,而后进行通讯。编程
在创建 Socket 的时候,应该设置什么参数呢?Socket 编程进行的是端到端的通讯,每每意识不到中间通过多少局域网,多少路由器,于是可以设置的参数,也只能是端到端协议之上网络层和传输层的。数组
对于网络层和传输层,有如下参数须要设置:缓存
两端建立了 Socket 以后,然后面的过程当中,TCP 和 UDP 稍有不一样,咱们先来看看 TCP。服务器
对于 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 编程过程和 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,这时候你能够建立一个紫禁城,而后将基于已链接的 Socket 交互交给这个新的子进程来作。就像来了一个新项目,你能够注册一家子公司,招人,而后把项目转包给这就公司作,这样你就又能够去接新的项目了。
这里有个问题是,如何建立子公司,并将项目移交给子公司?
在 Linux 下,建立子进程使用 fork 函数。经过名字能够看出,这是在父进程的基础上彻底拷贝一个子进程。在 Linux 内核中,会复制文件描述符的列表,也会复制内存空间,还会复制一条记录当前执行到了哪一行程序的进程。
这样,复制完成后,父进程和子进程都会记录当前刚刚执行完 fork。这两个进程刚复制完的时候,几乎如出一辙,只是根据 fork 的返回值来区分是父进程仍是子进程。若是返回值是 0,则是子进程,若是返回值是其余的整数,就是父进程,这里返回的整数,就是子进程的 ID。
进程复制过程以下图:
由于复制了文件描述符列表,而文件描述符都是指向整个内核统一的打开文件列表的。所以父进程刚才由于 accept 建立的已链接 Socket 也是一个文件描述符,一样也会被子进程得到。
接下来,子进程就能够经过这个已链接 Socket 和客户端进行通讯了。当通讯完成后,就能够退出进程。那父进程如何知道子进程干完了项目要退出呢?父进程中 fork 函数返回的整数就是子进程的 ID,父进程能够经过这个 ID 查看子进程是否完成项目,是否须要退出。
上面这种方式你应该能发现问题,若是每接一个项目,都申请一个新公司,而后干完了,就注销掉,实在是太麻烦了。并且新公司要有新公司的资产、办公家具,每次都买了再卖,不划算。
这时候,咱们应该已经想到了线程。相比于进程来说,线程更加轻量级。若是建立进程至关于成立新公司,而建立线程,就至关于在同一个公司成立新的项目组。一个项目作完了,就解散项目组,成立新的项目组,办公家具还能够共用。
在 Linux 下,经过 pthread_create 建立一个线程,也是调用 do_fork。不一样的是,虽然新的线程在 task 列表会新建立一项,可是不少资源,例如文件描述符列表、进程空间,这些仍是共享的,只不过多了一个引用而已。
下图是线程复制过程:
新的线程也能够经过已链接 Socket 处理请求,从而达到并发处理的目的。
上面两种方式,不管是基于进程仍是线程模型的,其实仍是有问题的。新到来一个 TCP 链接,就须要分配一个进程或者线程。一台机器能建立的进程和线程数是有限的,并不能很好的发挥服务器的性能。著名的C10K问题,就是说一台机器如何维护 1 万了链接。按咱们上面的方式,系统就要建立 1 万个进程或者线程,这是操做系统没法承受的。
那既然一个线程负责一个 TCP 链接不行,能不能一个进程或线程负责多个 TCP 链接呢?这就引出了下面两种方式。
当一个项目组负责多个项目时,就要有个项目进度墙来把控每一个项目的进度,除此以外,还得有我的专门盯着进度墙。
上面说过,Socket 是文件描述符,所以某个线程盯的全部的 Socket,都放在一个文件描述符集合 fd_set 中,这就是项目进度墙。而后调用 select 函数来监听文件描述符集合是否有变化,一旦有变化,就会依次查看每一个文件描述符。那些发生变化的文件描述符在 fd_set 对应的位都设为 1,表示 Socket 可读或者可写,从而能够进行读写操做,而后再调用 select,接着盯着下一轮的变化。
上面 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 问题的利器。
参考: