swoole - 面向连接(tcp)的心跳检测和合包分包

以下讲的是tcp连接,client和server是tcp协议不再强调

心跳检测

1,为什么需要心跳检测?

tcp连接中,socket_listen()相当于电话处于监听状态,有N个client等待连接,socket_accept()函数相当于接听一个电话,返回一个文件描述符fd(file description),在window中叫socket句柄,代表一个客户端连接。

$fd = socket_accept(resource $socket);

fd数量是有限制的,swoole中提到最大1600W,事实真的如此吗?每个连接都要消耗内存,实际fd数量取决于内存。超过server端设置的max_connect,新进入的连接将被拒绝。所以server会对连接空间时间(idle)过长的连接主动关闭,回收fd资源,达到复用的目的。

$serv->set([
	'heartbeat_idle_time'=>60, //客户端最大连接空间时间
	'heartbeat_check_interval'=>5, //每5秒服务端主动检测客户端连接的空闲时间
	]);
idle代表客户端连接空间时间,在redis 127.0.0.1:6379>client list中,打印的client信息中就包含idle参数,没错,redis也是这么回收连接资源的。

2,实现保持连接

如client想一直保持连接,则需定时向server发送数据,以免被server主动关闭连接。另个作用是检测连接是否正常,如三次server未响应,则发起重连。

实现很简单,client端只要一个定时器定时发送数据就可以了。尽量用较小的数据减少宽带消耗。

/**
* Client
*/
swoole_timer_tick(9000,function () use($client){
     $client->send('1');
});

合包分包

1,为什么需要合包、分包?

这得从套接字协议说起:

socket_create ( int $domain , int $type , int $protocol ) : resource
  • domain 协议族
  • type 数据传输类型
  • protocol 协议
详细请看php手册

创建一个TCP套接字:

$socket = socket_create(AF_INET,SOCK_STREAM,SOL_TCP);

传输类型SOCK_STREAM代表面向连接的套接字(stream流)。它被形象的比喻为“传送带”。TCP 协议即基于这种流式套接字。

传送带特征:

  • 可靠
  • 顺序传输
  • 没有数据边界

没有数据边界,即基于字节的传输方式。粘包处理正是体现“没有数据边界”这个特征。client、server端都需要处理粘包的问题。

å¨è¿éæå¥å¾çæè¿°

上图来自《tcp/ip网络编程》

图中一人为发送方,一人为接收方(并非发送方一定是客户端)。接收方为了提高效率,并非一有数据马上read,而是存在一个缓冲区(buffer),可能在缓冲区满后一次性读取,也可能未满时多次读取。

这么说来,发送方发送多条数据,接收方可能一次性读取;或者发送方发送一条大量数据,接收方分多次读取。在面向连接的套接字中,read函数和write函数调用次数并无太大意义。

通过实例来演示下:

情况一:发送方发送多条数据,接收方一次性读取

<?php
$client = new swoole_client(SWOOLE_SOCK_TCP);
$client->connect('127.0.0.1', 6001, -1);
/**
 * 10条数据分十次发送
 */
for ($i=0; $i < 11; $i++) {
	$client->send("hello!");
}

$client->close();
<?php
$serv = new swoole_server("127.0.0.1", 6001);
$serv->on('receive', function ($serv, $fd, $from_id, $data) {
    var_dump($data);
});
$serv->start();

server端是一次性读取的:

å¨è¿éæå¥å¾çæè¿°

情况二:发送方发送一条大量数据,接收方分多次读取

<?php
$client = new swoole_client(SWOOLE_SOCK_TCP);
$client->connect('127.0.0.1', 6001, -1);
/**
 * 发送一条较大的数据
 */
$client->send(str_repeat('a',32*1024));
$client->close();
<?php
$serv = new swoole_server("127.0.0.1", 6001);
$serv->on('receive', function ($serv, $fd, $from_id, $data) {
    var_dump(strlen($data));
});
$serv->start();

server端是多次读取的:

å¨è¿éæå¥å¾çæè¿°

以上总结合包分包处理的原因是 tcp缓冲区 和 SOCK_STREAM协议无边界的特性,除了这两个原因还有TCP拥有拥塞控制,数据包可能会延后发送。这些在实际开发中需特别注意。

2,如何解决?

swoole文档在 入门指导>快速起步>网络通讯协议 中给了两种解决方案。

第一种:EOF (end of file)

通过特定的分隔符来确认完整的数据。

<?php
$serv = new swoole_server("127.0.0.1", 6001);

//在接收方 设置两个选项
$serv->set([
	'open_eof_split'=>true,
	'package_eof'=>"\r\n\r\n"
	]);
$serv->on('receive', function ($serv, $fd, $from_id, $data) {
    var_dump(strlen($data));
});
$serv->start();

自动分包:

<?php

$client = new swoole_client(SWOOLE_SOCK_TCP);
$client->connect('127.0.0.1', 6001, -1);

/**

 * 10条数据分十次发送

 * 开启eof后数据结尾需加上自定义结束符,否则接收方无法接收到数据

 */
for ($i=0; $i < 5; $i++) {

	$client->send("hello!"."\r\n\r\n"); 

}

$client->close();

å¨è¿éæå¥å¾çæè¿°

自动合包

<?php
$client = new swoole_client(SWOOLE_SOCK_TCP);
$client->connect('127.0.0.1', 6001, -1);
/**
 * 发送一条较大的数据
 */
$client->send(str_repeat('a',32*1024)."\r\n\r\n");


$client->close();

å¨è¿éæå¥å¾çæè¿°

EOF方法,需保证数据中不能包含eof字符,否则会发生截取的数据不正确,但实际并不能保证数据中不包含eof字符。并且在截取数据时采用遍历数据进行eof字符匹配,有一定的性能消耗,因此通常使用第二种方法。
swoole也推荐包头+包体的方法

å¨è¿éæå¥å¾çæè¿°

第二种:固定包头+包体

原理:

å¨è¿éæå¥å¾çæè¿°

在数据data前,用几个字节保存data的长度,接收方根据长度来截取数据data。

如包体data=‘aaaaa’,包头用2个字节保存data长度5。

å¨è¿éæå¥å¾çæè¿°

接收方收到包后,先解析二进制格式包头,解析出5代表包体的长度为5,再从包(包体+包头=7长度)的第2字节(因包头占用2个字节长度)开始截取5的长度的数据即aaaaa。类似data = substr(包,2)。

那么接下来的重点是如何定义包头:

 1. 确保包头的长度固定(用多少个字节保存数据的长度),让接收方知道从包的哪个位置开始截取(偏移量),因为数据长度是不确定的。

 2. 其次包头的固定长度尽量的小,不占用过多资源,那么使用二进制来存贮是非常合适的。

 3. 不同的计算机保存和解析数据时顺序是不一致的(主机字节序),如整数值1在发送方的存贮方式是这样的:00000000 00000000 00000000 00000001,如果接收方和发送方的主机字节序相反,它保存的是:00000001 00000000 00000000 00000000。打个不恰当的例子,如发送方发送1234,先发送高位的1(千位),接收方接收到1,因它和发送方保存数据的顺序正好相反,它先保存低位的,1则被保存到最低位1(个位),接收完变成4321。因此出现个概念叫“网络字节序”,统一发送数据的顺序,接收方按照固定的函数将这种顺序的数据转成自己主机的主机字节序保存。例如统一发送顺序为4(个)-3(十)-2(百)-1(千) ----->接收方清楚1是高位的。(只是举例子,实际是二进制的)。

如何定义包头:

在swoole文档中Server>配置选项>package_length_type列举了包头的类型

å¨è¿éæå¥å¾çæè¿°

明白上边提到的三点,那么选择无符号的、网络字节序。即`N`、`n`,`N`能表示更多的整数值。

固定包头+包体的原理模型:

注意:只是演示这种方式的原理,有合包和分包的问题!
<?php
$client = new swoole_client(SWOOLE_SOCK_TCP);
$client->connect('127.0.0.1', 6001, -1);

/**
 * 打包
 */
$body = 'aaaaa'; 
//包头:将data的长度打包成4字节的二进制字符串
$head = pack('N',strlen($body)); 
$pack = $head.$body; //包头+包体

$client->send($pack);
$client->close();
<?php
$serv = new swoole_server("127.0.0.1", 6001);

$serv->on('receive', function ($serv, $fd, $from_id, $data) {

  var_dump(strlen($data)); //包头+包体长度=9
  	$len = unpack('N',$data); //unpack将$data中的二进制字符串解压缩到数组中
  var_dump($len); //array(1=>5)
  echo $body = substr($data,4,$len[1]); //substr($data,4,5)
   
});

$serv->start();

å¨è¿éæå¥å¾çæè¿°

swoole中的处理:

在swoole文档中Server>配置选项>package_length_check中给了示例

å¨è¿éæå¥å¾çæè¿°

这个设置是为了解决分包合包的问题,并没有自动处理封包解包的过程,这个要特别注意。

客户端保持不变,只是更改为循环发送多条数据:

<?php
$client = new swoole_client(SWOOLE_SOCK_TCP);
$client->connect('127.0.0.1', 6001, -1);

/**
 * 打包
 */
$body = 'aaaaa';
$head = pack('N',strlen($body));
$pack = $head.$body; 

for ($i=0; $i < 6 ; $i++) {
	$client->send($pack);
}

$client->close();

<?php
$serv = new swoole_server("127.0.0.1", 6001);

$serv->set([
	'open_length_check' => true, //开启打开包长检测特性
  'package_max_length' => 32*1024, //包的最大长度 过大会占用较多的输入缓冲区 下面单独介绍
  'package_length_type' => 'N', //包头长度类型
  'package_length_offset' => 0, //length长度值在包头的第几个字节
  'package_body_offset' => 4, //从第几个字节开始计算包体长度
]);

$serv->on('receive', function ($serv, $fd, $from_id, $data) {

  $len = unpack('N',$data);
  $body = substr($data,4,$len[1]);
  var_dump($body);
});

$serv->start();

å¨è¿éæå¥å¾çæè¿°

注意:当server启用了包头+包体方式,那么client端必须发送包头+包体格式的数据,否则会报错。

 'package_max_length':

表示服务端最大接收单次包的最大长度。

开启open_length_check/open_eof_check自动分包合包后,swoole底层会进行数据包拼接。这时在数据包未收取完整时,所有数据都是保存在内存中的。如果1w个tcp连接在发送数据,每个连接发送1M,那么接收方就会用10G的内存来存贮。

这是指swoole为了分包合包功能分配的内存而带来的注意事项,和输入输出缓冲区是单独的概念。

<?php
$serv = new swoole_server("127.0.0.1", 6001);

$serv->set([
	'worker_num'=>1,
	'open_length_check' => true,
    'package_max_length' => 20*1024,
    'package_length_type' => 'N',
    'package_length_offset' => 0,
    'package_body_offset' => 4,
]);

$serv->on('receive', function ($serv, $fd, $from_id, $data) {
   $len = unpack('N',$data);
   $body = substr($data,4,$len[1]);
   var_dump(strlen($body));
});
$serv->start();


<?php
$client = new swoole_client(SWOOLE_SOCK_TCP);
$client->connect('127.0.0.1', 6001, -1);

/**
 * 发送80*1024长度数据 大于了接收方设置的 'package_max_length'=20*1024
 */
$body = str_repeat('a',80*1024);
$head = pack('N',strlen($body));
$pack = $head.$body;
$client->send($pack);

$client->close();

报错信息:

å¨è¿éæå¥å¾çæè¿°

为了说明分包合包不仅仅用于客户端向服务端发送,下面例子服务端向客户端发送,其实大部分是一样的,采用同步客户端,有个小细节需要注意,即配置选项要在connect之前,否则不会生效,所以特别贴出来。

<?php
$serv = new swoole_server("127.0.0.1", 6001);

$serv->on('connect', function (swoole_server $serv, int $fd) {
    $body = str_repeat('a',1000*1024);
    $head = pack('N',strlen($body));
    $pack = $head.$body; 
    $serv->send($fd,$pack);
});
    
$serv->on('receive',function($serv, $fd, $reactor_id, $data){
    	echo 'received';
});  
$serv->start();   


<?php
$client = new swoole_client(SWOOLE_SOCK_TCP);

//注意同步客户端 设置选项在connect之前!
$client->set(array(
    'open_length_check'     => 1,
    'package_length_type'   => 'N',
    'package_length_offset' => 0,  
    'package_body_offset'   => 4,   
    'package_max_length'    => 100*1024, 
));

$client->connect('127.0.0.1', 6001, -1);

$data = $client->recv();
if($data){
	$len = unpack('N',$data);
	$body = substr($data,4,$len[1]);
	var_dump(strlen($body));
}

$client->close();   

å¨è¿éæå¥å¾çæè¿°

登录后进行讨论