http中 浏览器主动断开连接后, php脚本何时中断脚本执行

缘起

在PHP官网看到ignore_user_abort,官方这样介绍的:

PHP 以命令行脚本执行时,当脚本终端结束,脚本不会被立即中止,除非设置 value 为 true,否则脚本输出任意字符时会被中止。

从官方解释来说,当PHP脚本检测的客户端断开网络连接时,当PHP脚本继续向客户端输出内容时就会终止脚本的执行。
下面来验证下吧。

实验

步骤如下:

  1. 准备环境
    nginx(80端口) + php-fpm(9000端口) + php
location ~ .+\.php {
                set $script     $uri;
                set $path_info  "/";
                if ($uri ~ "^(.+\.php)(/.+)") {
                        set $script      $1;
                        set $path_info  $2;
                }                                                                                                                                                       
                fastcgi_pass 127.0.0.1:9000;
                fastcgi_index  index.php?IF_REWRITE=1;
                include         fastcgi_params;
                fastcgi_param PATH_INFO $path_info;
                fastcgi_param SCRIPT_FILENAME  $document_root$script;
                fastcgi_param SCRIPT_NAME $script;
                fastcgi_buffering on;
                fastcgi_buffers  2 256K;
                fastcgi_buffer_size 128K;
                fastcgi_busy_buffers_size 256k;
                fastcgi_temp_file_write_size 256k;

                fastcgi_connect_timeout 300;
                fastcgi_read_timeout  300;
                fastcgi_send_timeout  300;
        }

这个环境下,php echo 的内容经过三个缓冲区才会展示到浏览器页面,echo->PHP buffer ->nginx buffer ->浏览器 buffer,因为浏览器buffer的存在,你很难看到输出的内容动态展示到浏览器页面上,一般是浏览器接收完数据后,一起展现。

这也是为什么下面代码用PHP的输出控制函数flush系列,仅仅使用echo的话,输出不可控,很难得到想要的结果。

另外,如果没有出现想要的结果,考虑把nginx的缓冲区关闭fastcgi_buffering off;

PHP缓冲区相关内容请看缓冲函数简介

  1. 准备代码
public function test() {
        ignore_user_abort(TRUE);
        ob_end_clean();
        ob_start();
        for ($i = 0; $i < 6; $i++) {
            sleep(2);
            //if ($i < 2) {
            echo str_repeat('a', 4096);
            // }
            //$size = ob_get_length();
            //header("Content-Length: $size");
            log_message('error', "我正在做测试");
            if (connection_status() == CONNECTION_ABORTED) {
                log_message('error', "我该停止运行了" . connection_status() . '&&' . connection_aborted());
            }
            // if ($i < 2) {
            ob_flush();
            flush();
            // }
            
        }
        log_message('error', "我测试做完了");
    }
  1. 制造浏览器主动断开连接的情况
    通过http://www.test.com/test.php请求,然后浏览器主动断开连接
    断开方法:
    1):如果你对html,js,ajax熟悉,可以写个页面去请求该连接,为ajax请求添加超时时间
    timeout,(我用的这种,时间控制的更精确);
    2):浏览器F12,在控制台用window.stop()终止浏览器请求,时间自己估计。
  2. 实验结果
    1)令步骤2中,修改相应代码ignore_user_abort(FALSE);,在1000ms时主动断开可以看到日志如下:
ERROR - 2021-04-03 20:27:19 --> 我正在做测试
ERROR - 2021-04-03 20:27:21 --> 我正在做测试

可以看到脚本确实终止了,但输出了两次,这就有意思了,我们是1000ms时主动断开的,按照官方说法,不应该有输出的,因为我们PHP脚本里至少2s后才会有输出的,不急,往后看。。
2)令步骤2中,修改相应代码ignore_user_abort(TRUE);,在1000ms时主动断开可以看到日志如下:

ERROR - 2021-04-03 20:09:40 --> 我正在做测试
ERROR - 2021-04-03 20:09:42 --> 我正在做测试
ERROR - 2021-04-03 20:09:44 --> 我正在做测试
ERROR - 2021-04-03 20:09:44 --> 我该停止运行了1[]1
ERROR - 2021-04-03 20:09:46 --> 我正在做测试
ERROR - 2021-04-03 20:09:46 --> 我该停止运行了1[]1
ERROR - 2021-04-03 20:09:48 --> 我正在做测试
ERROR - 2021-04-03 20:09:48 --> 我该停止运行了1[]1
ERROR - 2021-04-03 20:09:50 --> 我正在做测试
ERROR - 2021-04-03 20:09:50 --> 我该停止运行了1[]1
ERROR - 2021-04-03 20:09:50 --> 我测试做完了

可以看到,PHP脚本在第三次输出时才有connection_status() == CONNECTION_ABORTED,可见是在第二次输出时PHP脚本才检测到客户端断开了连接,
3)令步骤2中,修改相应代码ignore_user_abort(TRUE);,在3000ms时主动断开可以看到日志如下:

ERROR - 2021-04-03 20:11:19 --> 我正在做测试
ERROR - 2021-04-03 20:11:21 --> 我正在做测试
ERROR - 2021-04-03 20:11:23 --> 我正在做测试
ERROR - 2021-04-03 20:11:25 --> 我正在做测试
ERROR - 2021-04-03 20:11:25 --> 我该停止运行了1[]1
ERROR - 2021-04-03 20:11:27 --> 我正在做测试
ERROR - 2021-04-03 20:11:27 --> 我该停止运行了1[]1
ERROR - 2021-04-03 20:11:29 --> 我正在做测试
ERROR - 2021-04-03 20:11:29 --> 我该停止运行了1[]1
ERROR - 2021-04-03 20:11:29 --> 我测试做完了

可以看到,PHP脚本在第四次输出时才有connection_status() == CONNECTION_ABORTED,可见是在第三次输出时PHP脚本才检测到客户端断开了连接
4)令步骤2中,修改相应代码ignore_user_abort(FALSE);,并把if($i<1)的条件打开,在1000ms时主动断开可以看到日志如下:

ERROR - 2021-04-03 20:20:57 --> 我正在做测试
ERROR - 2021-04-03 20:20:59 --> 我正在做测试
ERROR - 2021-04-03 20:21:01 --> 我正在做测试
ERROR - 2021-04-03 20:21:03 --> 我正在做测试
ERROR - 2021-04-03 20:21:05 --> 我正在做测试
ERROR - 2021-04-03 20:21:07 --> 我正在做测试
ERROR - 2021-04-03 20:21:07 --> 我测试做完了

5)令步骤2中,修改相应代码ignore_user_abort(FALSE);,并把if($i<2)的条件打开,在1000ms时主动断开可以看到日志如下:

ERROR - 2021-04-03 20:20:57 --> 我正在做测试
ERROR - 2021-04-03 20:20:59 --> 我正在做测试

那究竟是为什么呢?

查看PHP源码

随便到官网download一份PHP源码,我是php7.2.4的。
在这里插入图片描述
可以看到PHP项目的结构,根据我们这次要查的问题,我们主要关注ext(扩展库)和sapi(PHP的sapi层,里面包含对cgi协议的实现,比如php-fpm),我们使用ob_flush和flush函数将内容输出到客户端,那我们就关注这两个函数好了,go…
ps:这里默认你了解如何给PHP写一个扩展;
直接在项目里搜索PHP_FUNCTION(ob_flush)和PHP_FUNCTION(flush),通过对这两个函数跟踪,可以看到都会调用sapi_flush()函数,其内容如下:

SAPI_API int sapi_flush(void)
{
	if (sapi_module.flush) {
		sapi_module.flush(SG(server_context));
		return SUCCESS;
	} else {
		return FAILURE;
	}
}

可以看到该函数的作用是调用相应的sapi_module,即sapi_module_struct结构体的flush变量对应的函数,我们用的是sapi下的fpm模块,那么到fpm下搜索flush关键字,可以在fpm_main.c文件下的sapi_module_struct结构体中看到flush变量对应的函数是sapi_cgibin_flush函数,该函数内容如下:

tatic void sapi_cgibin_flush(void *server_context) /* {{{ */
{
	/* fpm has started, let use fcgi instead of stdout */
	if (fpm_is_running) {
		fcgi_request *request = (fcgi_request*) server_context;
		if (
#ifndef PHP_WIN32
	      !parent &&
#endif
	      request && !fcgi_flush(request, 0)) {
			php_handle_aborted_connection();
		}
		return;
	}

	/* fpm has not started yet, let use stdout instead of fcgi */
	if (fflush(stdout) == EOF) {
		php_handle_aborted_connection();
	}
}

其中与我们今天聊的密切相关的有fcgi_flush()【在main目录下的fastcgi.c文件中】和php_handle_aborted_connection()函数,先看fcgi_flush()函数,fcgi_flush会调用safe_write()函数将我们的内容输出,safe_write内容如下:

static inline ssize_t safe_write(fcgi_request *req, const void *buf, size_t count)
{
	int    ret;
	size_t n = 0;

	do {
#ifdef _WIN32
		size_t tmp;
#endif
		errno = 0;
#ifdef _WIN32
		tmp = count - n;

		if (!req->tcp) {
			unsigned int out_len = tmp > UINT_MAX ? UINT_MAX : (unsigned int)tmp;

			ret = write(req->fd, ((char*)buf)+n, out_len);
		} else {
			int out_len = tmp > INT_MAX ? INT_MAX : (int)tmp;

			ret = send(req->fd, ((char*)buf)+n, out_len, 0);
			if (ret <= 0) {
				errno = WSAGetLastError();
			}
		}
#else
		ret = write(req->fd, ((char*)buf)+n, count-n);//C语言原生write函数
#endif
		if (ret > 0) {
			n += ret;
		} else if (ret <= 0 && errno != 0 && errno != EINTR) {
			return ret;
		}
	} while (n != count);
	return n;
}

可以看到linux系统下直接调用c原生函数write向相应的网络IO(tcp)中写内容,当write写失败(比如当前的tcp连接关闭),sapi_cgibin_flush()就会调用php_handle_aborted_connection()函数,该函数内容如下:

PHPAPI void php_handle_aborted_connection(void)
{

	PG(connection_status) = PHP_CONNECTION_ABORTED;//将PHP内部连接状态置为CONNECTION_ABORTED
	php_output_set_status(PHP_OUTPUT_DISABLED);

	if (!PG(ignore_user_abort)) {//如果ignore_user_abort函数返回false,就调用zend_bailout函数退出脚本
		zend_bailout();
	}
}

可以看到该函数的作用就是将PHP内部连接状态置为CONNECTION_ABORTED,如果ignore_user_abort函数返回false,就调用zend_bailout函数退出脚本,true的话脚本继续运行。

抓包查看

我是linux服务器,用的tcpdump抓的包,命令如下(windows考虑wireshark):

tcpdump -i any port 9000 -U -n -w /home/php.pcap

将实验环节步骤四的三次实验进行抓包,获取php.pcap文件,并用wireshark打开(不要问为什么,问就是方便查看),如下图:
在这里插入图片描述
在这里插入图片描述
其中【1-9】是实验环节步骤四的第一次实验的;
【10-18】是实验环节步骤四的第二次实验的;
【19-40】是一次完整的请求,这次没有让客户端主动断开连接;
【41-51】是实验环节步骤四的第三次实验的;

通过对三次抓包进行分析,可以确定,当PHP脚本在输出时(遵循fastcpi协议),遇到tcp的RST包时,就会将PHP内部的连接状态置为ABORTED,具体看官网连接状态
这样当ignore_user_abort(FALSE);时,PHP立即终止脚本并退出,
ignore_user_abort(TRUE);时,PHP脚本就不会退出了,你可以通过connection_status() 和connection_aborted()来确定PHP内部的连接状态。

这样也就解释通了二三次实验的情况(这里默认你对HTTP,TCP协议熟悉),

  1. 当客户端主动中断连接时,向服务端发送FIN包;
  2. 服务端收到FIN包并回ACK包;
  3. 服务端继续向客户端发送数据包(TCP协议允许收到FIN包后继续向另一端发送数据包的);
  4. 客户端一看,我不要数据了,你咋还发,就发了个RST包,这样服务端收到RST包就知道客户端不要数据了,就不发了,关闭TCP连接,并释放相关资源(状态、数据、端口号等)

可以看到,在客户端主动断开连接后,PHP至少向客户端再发送两次数据包才能知道客户端主动断开了连接,第一次为了让客户端发送RST包,然后服务端操作系统关闭相应的TCP连接,第二次是用来检测TCP连接已关闭,检测方法就是write函数写数据失败,所以第二次试验在第三次输出时才有connection_status() == CONNECTION_ABORTED,而第三次在第四次输出时才有connection_status() == CONNECTION_ABORTED,这样解释了第四,五次实验结果

应用场景

1:可以令ignore_user_abort(TRUE);,响应客户端所需内容后,继续让脚本执行别的任务
这个时候要通过php header函数好好设计响应头:

	header("Connection: close"); // 告诉浏览器,连接关闭了,这样浏览器就不用等待服务器的响应
	header("HTTP/1.1 200 OK"); //可以发送200状态码,以这些请求是成功的,要不然可能浏览器会重试,特别是有代理的情况下
	header("Content-Encoding: none");
	header("Content-Length: $size");//最关键的一个,浏览器会在收到这么多内容后就停止请求了

2:要想实现客户端主动断开连接,PHP脚本自动退出,释放相应的资源,至少要向客户端(浏览器等)进行两次输出,条件比较苛刻,也完全没必要,因为正常应用中,程序逻辑肯定是处理完所有业务才会进行输出的

题外话

PHP的内部连接状态有三个:

0 - NORMAL(正常)
1 - ABORTED(异常退出)
2 - TIMEOUT(超时)

0没啥说的,1也没啥说的(我们前面说的客户端主动断开连接,算是这里面的一种吧),2呢?很明显和php.ini里的max_execution_time 有直接关系。默认30s超时,

再来看官网对于max_execution_time的提示:
The set_time_limit() function and the configuration directive max_execution_time only affect the execution time of the script itself. Any time spent on activity that happens outside the execution of the script such as system calls using system(), stream operations, database queries, etc. is not included when determining the maximum time that the script has been running. This is not true on Windows where the measured time is real.
意思是 max_execution_time 计算的只是PHP脚本本身执行的时间,执行之外的时间都不会计算在内。哪些属于执行之外的时间呢?包含socket交互,系统调用(如sleep),系统操作等等。

另外当你调用set_time_limit,ini_set函数来设置max_execution_time时,PHP会重新计时,也就是说,如果最长执行时间为预设的30秒,而在呼叫此函式set_time_limit(20)之前已花了25秒来执行程式,则程式最长执行的时间将会是45秒。

参考资料

1:TCP RST出现的几种情况
2:PHP flush函数
3:PHP缓冲区
4:ob_flush与flush的区别
5:浏览器退出之后php还会继续执行?

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值