模仿workerman的玩具

2018年7月25日 0 作者 筱枫

前两天写了个workerman的代码分析,现在就是自己模仿其写个类似的玩具,代码尽可能简单
首先,来写个最简单的socket程序,要求是启动后,用浏览器访问能够得到hello,world!即可
代码很简单,看下面

$ip = "0.0.0.0";
$port = "8080";

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

socket_set_option($sock,SOL_SOCKET, SO_REUSEADDR, 1);

socket_bind($sock, $ip, $port);
socket_listen($sock);

echo "start listen!\n";

$connectSock = socket_accept($sock);

if (false !== $connectSock)
    socket_write($connectSock,
        "HTTP/1.1 200 OK
Content-Type: text/html;charset=utf-8
Content-Length: 13

Hello, world!");

socket_close($connectSock);
socket_close($sock);
echo "sock already close!\n";

接着在命令行中用php xxx.php启动即可,注意端口8080如果被占用,可以换个其他的端口
之后使用浏览器直接访问 服务器ip:8080即可看到hello,world!
下面来一步步解释下代码,首先创建了一个套接字,接着设置地址可复用,其实不设置这个参数也是可以,但这样就会出现第一个程序关闭后再打开会出现address already use的错误
之后绑定这个套接字至ip和端口,注意,这里的ip写0.0.0.0表示通配所有,你如果只想本机连接,可以直接使用127.0.0.1
然后监听套接字

最后,使用socket_accept阻塞等待连接,如果有连接,这个套接字会立马返回,并且返回一个可以实际读取数据的套接字,注意!原始的sock是无法直接被读写的!
之后则是很常规的写入标准http的信息进去,这里要注意的一点是,所有的协议头前不要有换行符,如果你写成了

socket_write($connectSock,
        "HTTP/1.1 200 OK
		Content-Type: text/html;charset=utf-8
		Content-Length: 13

		Hello, world!");

浏览器是不会解析的,因为Content-Type前面实际上有两个制表符符号,同时这里面的换行符也不能被省略
当然,你这么写也是可以的

socket_write($connectSock,
        "HTTP/1.1 200 OK\nContent-Type: text/html;charset=utf-8\nContent-Length: 13\n\nHello, world!");

一样没有任何问题,到结尾就是关闭套接字,很常规化的操作。

采用套接字(socket)开发,可以避免陷入复杂的tcp三次握手、重传等细节当中去
这样一个简单的服务器就搭建完成了,很简单,不是么?
这就是阻塞模型,最简单的方式,缺点是性能受影响,最大的问题在于socket_accept这里,当能够继续读取之后,就要开个新的线程去处理数据,当然在当前这个线程处理也是可以的

那我们来改写一下,采用select的方式
新的代码如下

$ip = "0.0.0.0";
$port = "8080";

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

socket_set_option($sock,SOL_SOCKET, SO_REUSEADDR, 1);
socket_set_nonblock($sock);

socket_bind($sock, $ip, $port);
socket_listen($sock, 4);

$_read = [(int)$sock => $sock];

echo "start listen!\n";
while (true)
{
    $read  = $_read;
    $write = [];
    $except = [];

    socket_select($read, $write, $except, 100000000);
    var_dump($read);

    foreach ($read as $value)
    {
        if ($value == $sock)
        {
            $msgSocket = socket_accept($value);

            if (false !== $msgSocket)
                $_read[(int)$msgSocket] = $msgSocket;
        }
        else
        {
            $msg = socket_read($value, 8192);
            echo($msg);
            unset($_read[(int)$value]);
            socket_write($value, httpEncode("你好,世界!"));
            socket_close($value);
        }
    }
}

socket_close($sock);
echo "close socket!\n";

function httpEncode(string $str)
{
    $length = strlen($str);
    return
"HTTP/1.1 200 OK
Content-Type: text/html;charset=utf-8
Content-Length: {$length}

{$str}";
}

代码跟之前相比没有太大的变化,将其中的http编码的东西提取出来了,可以复用
其他的主要就是设置为非阻塞模式,然后开始进行轮询

socket_select($read, $write, $except, 100000000);

这行代码,主要是筛选传进去的$read是否是可读
如果之前有两个socket1和socket2,但只有socket1可读,那么这个函数执行后,$read里面就是socket1,因为它是引用传参,所以会改变其中的内容
例如 $read = [socket1, socket2];
执行后
$read = [socket1]

再下面就是模仿workerman进行处理,如果有新的连接,则接收新连接,并将新的套接字放入$read数组中等待读取
如果这个新连接可以读取,则直接读取,然后输出“你好,世界!”即可
代码总体还是非常简单,只是需要注意一些其中的细节。

接下来,我们给这个程序加上fork,用以创建子进程
我们的代码加在第十二行的地方

socket_listen($sock, 4);

$_read = [(int)$sock => $sock];

$childrenNum = 4;

while ($childrenNum--)
{
    // 子进程直接跳出循环,开始监听
    // 父进程则继续创建子进程,直到达到上限为止
    if (pcntl_fork() === 0)
        break;
}

$pid = getmypid();
echo "start listen!{$pid}\n";

接着,我们让进程打出他们自己的进程号
然后现在来启动一下

如图,4个子线程,一个主线程,一共五个,其实我们也可以让主线程在最后直接退出即可,代码也很简单,直接判断$childrenNum === 0即可
接下来,让我们在打印消息的地方,也就是原本的34行前加一段代码,用于打印出pid,这样,我们就可以知道是哪个进程在处理这个socket连接

$msg = socket_read($value, 8192);
echo "PID: ".getmypid()."\n";
echo($msg);

接着再重新启动,然后用浏览器访问下,这里我用了两个不同的浏览器访问,所以可以看到是不同的子进程在处理socket连接

当一个socket资源被不同的进程所持有时,由内核尽量公平的分配,这就是为什么会是不同的子进程处理的原因
读者可以多试试,当一个浏览器访问时,是由这个子进程处理,那么接下来,这个浏览器的请求都是由这个子进程处理了。

本文到这里便告一段落了,剩下的也没有什么太多深究的地方,无非就是些信号量,进程间的通讯问题了,与这些核心内容没有太多的关联,如果有兴趣,可以看看 “linux进程通信”之类的信息

最后附上个人的简单压测代码

$i = 5000;
$startTime = time();
while ($i--)
{
    $ch = curl_init();
    curl_setopt($ch, CURLOPT_URL, 'test.test:8080');
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
    $output = curl_exec($ch);
    echo $output;
    echo "\n$i\n";
    curl_close($ch);
}

echo "end Time:".(time() - $startTime);

测试环境为子进程设置为4,然后压测脚本通过php启动,一共开了5个
在我这台虚拟机上的测试结果
使用阻塞连接: 81秒
使用select+轮询的方式:50秒
可以看到,效率获得了巨大的提升,如果再使用libevent的话,效率还能更高