模仿workerman的玩具
前两天写了个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的话,效率还能更高