最早的网络通信是两台联网的计算机是通过操作系统的套接字(Socket)部件进行通信的,Socket也成为后来网络通信的基础部件。随着网络通信的普及,多台计算机连接网络,网络通信分层,经典的OSI的7层协议、TCP/IP的四层协议这里就不再赘述。 重点讲下基于TCP的客户端与服务器通信的演进,大致如下:
单进程阻塞的网络服务器
早期网络通信是简单的客户端发起请求,服务器处理并响应的方式,服务器是单进程处理模式,即服务端开启一个进程阻塞监听请求,通过Socket通信处理请求,响应给客户端。这种通信模式,服务器一次只能处理一个请求,处理完成后才能接收另一个请求。如下图:
说明:
- 服务器开启一个进程,创建一个socket,绑定服务器端口(bind),监听端口(listen);
- Soket监听阻塞(accept操作),等待客户端连接进入。此时程序会进入睡眠状态,直到有新的客户端发起请求到服务器,操作系统socket_recv会唤醒此进程,将服务器数据从从内核空间复制到用户空间,程序在用户空间进行数据处理。
- 利用fread读取客户端socket当中的数据收到数据后服务器程序进行处理然后使用fwrite向客户端发送响应。长连接的服务会持续与客户端交互,而短连接服务一般收到响应就会关闭Socket(close)。
缺点:1、一次只能处理一个连接,不支持多个连接同时处理,每个请求进入到我们的服务端的时候,单独创建一个进程/线程提供服务。
预派生子进程模式
借鉴单进程阻塞方式的缺点,每请求一次只能一个进程接收处理请求,客户端响应时间也会延迟,如果有多个进程同时处理客户端请求,处理完即释放等待下一个连接,这样就可以同时接收处理更多的请求,于是就有了预派生子进程的模式,预先创建多个子进程进行处理请求,如下图。
说明:
前面Socket监听处理流程与单进程阻塞流程基本一致就不再补充了。
1、程序启动后就会预先创建N个子进程。每个子进程进入 Accept,等待新的连接进入。当客户端连接到服务器时,其中一个子进程会被唤醒,开始处理客户端请求,并且不再接受新的TCP连接。当此连接关闭时,子进程会释放,重新进入 Accept,参与处理新的连接。
优点:
这个模型的优势是完全可以复用进程,不需要太多的上下文切换,比如php-fpm基于此模型的。
缺点:
- 这种模型严重依赖进程的数量解决并发问题,一个客户端连接就需要占用一个进程,工作进程的数量有多少,并发处理能力就有多少。操作系统可以创建的进程数量是有限的。
- 操作系统生成一个子进程需要进行内存复制等操作,在资源和时间上会产生一定的开销;当有大量请求时,会导致系统性能下降;
例如:即时聊天程序,一台服务器可能要维持数十万的连接,那么就要启动数十万的进程来维持。这显然不可能
基于上面的模式我们发现我们只能通过每次(accept)处理单个请求,没办法一次性处理多个请求?
IO复用
上述方式改进了一些通信的缺点,但是需要创建服务器进程,而创建进程需要的资源开销很大,对服务器压力也是很大的,于是单进程阻塞复用方式的网络通信,即IO复用。
IO即Input-Output输入-输出,指输入输出设备的处理,比如凡是涉及到数据交换的地方,内存、磁盘、网络的操作处理都是IO。IO多路复用是指内核一旦发现进程指定的一个或者多个IO条件准备读取,它就通知该进程,目前支持I/O多路复用的模式有 select,poll,epoll,I/O多路复用就是通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。
IO复用-Select模型
Select模型,原理是以服务端轮询方式监听每个客户端请求,若有请求,服务端进行Socket通信处理,响应给客户端。详细来说,监视并等待多个文件描述符的属性变化(可读、可写或错误异常)。select函数监视的文件描述符分 3 类,分别是writefds、readfds、和 exceptfds。调用后 select会阻塞,直到有描述符就绪(有数据可读、可写、或者有错误异常),或者超时( timeout 指定等待时间),函数才返回。当 select()函数返回后,可以通过遍历 fdset,来找到就绪的描述符,并且描述符最大不能超过1024
说明:
服务监听流程如上
- 保存所有的socket,通过select模型,监听socket描述符(fd)的可读事件
- Select会在内核空间监听一旦发现socket可读,会从内核空间传递至用户空间,在用户空间通过逻辑判断是服务端socket可读,还是客户端的socket可读
- 如果是服务端的socket可读,说明有新的客户端建立,将socket保留到监听数组当中
- 如果是客户端的socket可读,说明当前已经可以去读取客户端发送过来的内容了,读取内容,然后响应给客户端。
缺点:
- select模式本身的缺点(1、循环遍历处理事件、2、内核空间传递数据的消耗);
- 单进程对于大量任务处理乏力;
IO复用-poll模型
poll的机制与select类似,与select在本质上没有多大差别,管理多个描述符也是进行轮询,根据描述符的状态进行处理,但是poll没有最大文件描述符数量的限制。poll和select同样存在一个缺点就是,包含大量文件描述符的数组被整体复制于用户态和内核的地址空间之间,而不论这些文件描述符是否就绪,它的开销随着文件描述符数量的增加而线性增大。
问题:
select/poll问题很明显,它们需要循环检测连接是否有事件。如果服务器有上百万个连接,在某一时间只有一个连接向服务器发送了数据,select/poll需要做循环100万次,其中只有1次是命中的,剩下的99万9999次都是无效的,白白浪费了CPU资源。
IO复用-epoll模型
epoll是在2.6内核中提出的,是之前的select和poll的增强版本。相对于select和poll来说,epoll更加灵活,没有描述符限制,无需轮询。epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中。
简单点来说就是当连接有I/O流事件产生的时候,epoll就会去告诉进程哪个连接有I/O流事件产生,然后进程就去处理这个进程。
这里可以多加一个选择nginx的原因,因为Nginx是基于epoll的异步非阻塞的服务器程序。自然,Nginx能够轻松处理百万级的并发连接,也就无可厚非了。
多进程master-worker模型
编程实现案例
下面以代码方式展示以上网络通信模式,以便深刻理解通信原理。
IO复用-epoll模型
服务端程序,epoll_server.php,如下:
<?php
class Worker{
//监听socket
protected $socket = NULL;
//连接事件回调
public $onConnect = NULL;
public $reusePort=1;
//接收消息事件回调
public $onMessage = NULL;
public $workerNum=3; //子进程个数
public $allSocket; //存放所有socket
public $addr;
protected $worker_pid; //子进程pid
protected $master_pid;//主进程id
public function __construct($socket_address) {
//监听地址+端口
$this->addr=$socket_address;
$this->master_pid=posix_getpid();
}
public function start() {
//获取配置文件
$this->fork($this->workerNum);
$this->monitorWorkers(); //监视程序,捕获信号,监视worker进程
}
/**
* 捕获信号
* 监视worker进程.拉起进程
*/
public function monitorWorkers(){
//注册信号事件回调,是不会自动执行的
// reload
pcntl_signal(SIGUSR1, array($this, 'signalHandler'),false); //重启woker进程信号
//ctrl+c
$status=0;
while (1){
// 当发现信号队列,一旦发现有信号就会触发进程绑定事件回调
pcntl_signal_dispatch();
$pid = pcntl_wait($status); //当信号到达之后就会被中断
pcntl_signal_dispatch();
//进程重启的过程当中会有新的信号过来,如果没有调用pcntl_signal_dispatch,信号不会被处理
}
}
public function signalHandler($sigo){
switch ($sigo){
case SIGUSR1:
$this->reload();
echo "收到重启信号";
break;
}
}
public function fork($worker_num){
for ($i=0;$i<$worker_num;$i++){
$pid=pcntl_fork(); //创建成功会返回子进程id
if($pid<0){
exit('创建失败');
}else if($pid>0){
//父进程空间,返回子进程id
$this->worker_pid[]=$pid;
}else{ //返回为0子进程空间
$this->accept();//子进程负责接收客户端请求
exit;
}
}
//放在父进程空间,结束的子进程信息,阻塞状态
}
public function accept(){
$opts = array(
'socket' => array(
'backlog' =>10240, //成功建立socket连接的等待个数
),
);
$context = stream_context_create($opts);
//开启多端口监听,并且实现负载均衡
stream_context_set_option($context,'socket','so_reuseport',1);
stream_context_set_option($context,'socket','so_reuseaddr',1);
$this->socket=stream_socket_server($this->addr,$errno,$errstr,STREAM_SERVER_BIND|STREAM_SERVER_LISTEN,$context);
//第一个需要监听的事件(服务端socket的事件),一旦监听到可读事件之后会触发
swoole_event_add($this->socket,function ($fd){
$clientSocket=stream_socket_accept($fd);
//触发事件的连接的回调
if(!empty($clientSocket) && is_callable($this->onConnect)){
call_user_func($this->onConnect,$clientSocket);
}
//监听客户端可读
swoole_event_add($clientSocket,function ($fd){
//从连接当中读取客户端的内容
$buffer=fread($fd,1024);
//如果数据为空,或者为false,不是资源类型
if(empty($buffer)){
if(is_resource($fd) || feof($fd) ){
//触发关闭事件
fclose($fd);
}
}
//正常读取到数据,触发消息接收事件,响应内容
if(!empty($buffer) && is_callable($this->onMessage)){
call_user_func($this->onMessage,$fd,$buffer);
}
});
});
}
/**
* 重启worker进程
*/
public function reload(){
foreach ($this->worker_pid as $index=>$pid){
posix_kill($pid,SIGKILL); //结束进程
var_dump("杀掉的子进程",$pid);
unset($this->worker_pid[$index]);
$this->fork(1); //重新拉起worker
}
}
//捕获信号之后重启worker进程
}
$worker = new Worker('tcp://0.0.0.0:9800');
//开启多进程的端口监听
$worker->reusePort = true;
//连接事件
$worker->onConnect = function ($fd) {
//echo '连接事件触发',(int)$fd,PHP_EOL;
};
$worker->onTask = function ($fd) {
//echo '连接事件触发',(int)$fd,PHP_EOL;
};
//消息接收
$worker->onMessage = function ($conn, $message) {
//事件回调当中写业务逻辑
//var_dump($conn,$message);
$content="我是yunyan";
$http_resonse = "HTTP/1.1 200 OK\r\n";
$http_resonse .= "Content-Type: text/html;charset=UTF-8\r\n";
$http_resonse .= "Connection: keep-alive\r\n"; //连接保持
$http_resonse .= "Server: php socket server\r\n";
$http_resonse .= "Content-length: ".strlen($content)."\r\n\r\n";
$http_resonse .= $content;
fwrite($conn, $http_resonse);
};
$worker->start(); //启动
客户端用浏览器访问:http://ip:9800
说明:
本程序中,主要处理如下:
1.初始化指定了工作进程数worknum,主进程marster_pid,工作进程worker_pid等服务器相关进程配置参数;
2.服务器启动时,按工作进程数worknum拉起工作子进程,并缓存工作子进程实例pid到一个数组实例变量待用,启动监听请求,同时启动监听信号方法,接收到中断信号时,销毁工作子进程,防止出现僵尸进程。
3.客户端请求时,工作子进程分别接收请求,处理并返回响应给客户端。处理完毕后,子进程释放socket连接。