网络协议九之socket编程
前面一直在说各种协议,偏理论方面的知识,这次咱们就来认识下基于 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 问题。
之前我们基本了解了网络通信里的大部分协议,一直都是在“听”的过程。很多人都会觉得,好像看懂了,但关了页面回忆起来,好像又什么都没懂。这次咱们就“真枪实弹”的码起来,再用一个“神器”-网络分析系统详细跟踪下数据包的生命历程,让我们的理论真实的呈现出来,对网络通信感兴趣的博友,还可以自己拿着系统分析一遍,你一定会大有所获。
不多说,直接上代码。有兴趣的博友可以按各编程语言进行相关改写,然后拿着我们的分析系统真实的看看网络通信过程。
本机请求转发到网关
代码中的 192.168.1.10 是内网另一台服务器,楼主的 IP 是 192.168.1.73。在本机跑服务器的时候,要做一个路由配置,否则分析系统无法抓取相关的包。window 下可按下面步骤配置:
- 管理员身份打开 DOS 窗口;
- route add 本机ip mask 255.255.255.255 网关ip(路由转发,还记得吗?忘记了?点我点我点我);
什么?不知道怎么查 IP 和网关?点我告诉你
操作完成后记得删除转发规则,否则,你会发现本机的请求,速度会变得很慢、、、
实例:
// 添加路由转发规则
route add 192.168.1.73 mask 255.255.255.255 192.168.1.1
// 删除转发规则
route delete 192.168.1.73
基于 TCP 的 Socket
服务端:
<?php
/**
* 1. socket_create: 新建 socket
* 2. socket_bind: 绑定 IP 和 port
* 3. socket_listen: 监听
* 4. socket_accept: 接收客户端连接,返回连接 socket
* 5. socket_read: 读取客户端发送数据
* 6. socket_write: 返回数据
* 7. socket_close: 关闭 socket
*/
$ip = '192.168.1.10';
$port = 23333;
// $port = 80;
$sk = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
!$sk && outInfo('socket_create error');
// 绑定 IP
!socket_bind($sk, $ip, $port) && outInfo('socket_bind error');
// 监听
!socket_listen($sk) && outInfo('sever listen error');
outInfo("Success Listen: $ip:$port", 'INFO');
while (true) {
$accept_res = socket_accept($sk);
!$accept_res && outInfo('sever accept error');
$reqStr = socket_read($accept_res, 1024);
if (!$reqStr) outInfo('sever read error');
outInfo("Server receive client msg: $reqStr", 'INFO');
$response = 'Hello A, I am B. you msg is : ' . $reqStr . PHP_EOL;
if (socket_write($accept_res, $response, strlen($response)) === false) {
outInfo('response error');
}
socket_close($accept_res);
}
socket_close($sk);
function outInfo($errMsg, $level = 'ERROR')
{
if ($level === 'ERROR') {
$errMsg = "$errMsg, msg: " . socket_strerror(socket_last_error());
}
echo $errMsg . PHP_EOL;
$level === 'ERROR' && die;
}
客户端:
<?php
/**
* 1. socket_create: 新建 socket
* 2. socket_connect: 连接服务端
* 3. socket_write: 给服务端发数据
* 4. socket_read: 读取服务端返回的数据
* 5. socket_close: 关闭 socket
*/
$ip = '192.168.1.10';
$port = 23333;
$sk = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
!$sk && outInfo('socket_create error');
!socket_connect($sk, $ip, $port) && outInfo('connect fail');
$msg = 'hello, I am A';
if (socket_write($sk, $msg, strlen($msg)) === false) {
outInfo('socket_write fail');
}
while ($res = socket_read($sk, 1024)) {
echo 'server return message is:'. PHP_EOL. $res;
}
socket_close($sk);//工作完毕,关闭套接流
function outInfo($errMsg, $level = 'ERROR')
{
if ($level === 'ERROR') {
$errMsg = "$errMsg, msg: " . socket_strerror(socket_last_error());
}
echo $errMsg . PHP_EOL;
$level === 'ERROR' && die;
}
上面的代码是基于 PHP 原生 Socket 写的,其它语言也有对应 Socket 操作函数,进行相关的改写即可。主要是下面的分析过程。

如上图,这是我们的分析系统捕捉的所有数据传输过程,你可以真实的看到每一步都发生了什么,以及对应的状态的改变(图片较大,建议右键在新标签页打开看)。
在图中上半部分,我们可以看到分析系统将整个 TCP 的生命历程分为了三个阶段:建立连接、交易、关闭连接。这和我们之前了解的理论知识完全相符。
左下角的交易时序图,则详细记录了客户端和服务端每次通信的详细信息,而右下角部分,则展示了每次通信,数据包的状态等信息。
基于 UDP 的Socket
<?php
/**
* 1. socket_create: 新建 socket
* 2. socket_bind: 绑定 IP 和 port
* 3. socket_recvfrom: 读取客户端发送数据
* 4. socket_sendto: 返回数据
* 5. socket_close: 关闭 socket
*/
$ip = '192.168.1.10';
$port = 23333;
$sk = socket_create(AF_INET, SOCK_DGRAM, SOL_UDP);
!$sk && outInfo('socket_create error');
// 绑定 IP
!socket_bind($sk, $ip, $port) && outInfo('socket_bind error');
outInfo("Success Listen: $ip:$port", 'INFO');
while (true) {
$from = '';
$reqPort = 0;
if (!socket_recvfrom($sk, $buf, 1024, 0, $from, $reqPort)) {
outInfo('sever socket_recvfrom error');
}
outInfo("Received msg $buf from remote address $from:$port", 'INFO');
$response = "Hello $from:$port, I am Server. your msg : " . $buf . PHP_EOL;
if (!socket_sendto($sk, $response, strlen($response), 0, $from, $reqPort)) {
outInfo('socket_sendto error');
}
}
socket_close($sk);
function outInfo($errMsg, $level = 'ERROR')
{
if ($level === 'ERROR') {
$errMsg = "$errMsg, msg: " . socket_strerror(socket_last_error());
}
echo $errMsg . PHP_EOL;
$level === 'ERROR' && die;
}
客户端:
<?php
/**
* 1. socket_create: 新建 socket
* 2. socket_write: 给服务端发数据
* 3. socket_read: 读取服务端返回的数据
* 4. socket_close: 关闭 socket
*/
$ip = '192.168.1.10';
$port = 23333;
$sk = socket_create(AF_INET, SOCK_DGRAM, SOL_UDP);
!$sk && outInfo('socket_create error');
$msg = 'hello, I am A';
if (!socket_sendto($sk, $msg, strlen($msg), 0, $ip, $port)) {
outInfo('socket_sendto fail');
}
$from = '';
$reqPort = 0;
if (!socket_recvfrom($sk, $buf, 1024, 0, $from, $reqPort)) {
outInfo('server socket_recvfrom error');
}
outInfo("Received $buf from server address $from:$port", 'INFO');
socket_close($sk);
function outInfo($errMsg, $level = 'ERROR')
{
if ($level === 'ERROR') {
$errMsg = "$errMsg, msg: " . socket_strerror(socket_last_error());
}
echo $errMsg . PHP_EOL;
$level === 'ERROR' && die;
}
UDP 数据包分析图:
如上图,UDP 数据包分析图,明显比 TCP 要简单很多,人家单纯嘛,就不多说了。不过要注意的,写代码的时候,UDP 的服务端,在循环里千万不要关闭 Socket。

浙公网安备 33010602011771号