针对CURL的一些简要分析记录

AI 摘要: 本文主要介绍了PHP中curl扩展的常见用法以及在开发中遇到的问题。curl扩展支持多种协议的网络服务调用,并可以设置超时时间和其他属性。使用curl_exec执行请求时,会同步阻塞直到收到数据或超时。同时介绍了使用curl_multi_exec进行多个请求的并发执行,以及while循环中使用curl_exec不会引起系统负载过高的情况。

PHP的curl扩展是在PHP Web应用中常见的网络服务调用方式,支持http、https、ftp等协议,编译的过程中是依赖LibCurl包,libcurl 同时支持 HTTPS 证书、HTTP POST、HTTP PUT、 FTP 上传(也能通过 PHP 的 FTP 扩展完成)、HTTP 基于表单的上传、代理、cookies、用户名+密码的认证。

另外,目前另外一种调用网络常用方式是基于基于GuzzleHttp包1可以不依赖cURL来发送HTTP请求(基于PHP流包装程序发送HTTP请求)

本文主要是基于curl的扩展,在使用curl扩展在开发过程中遇到的一些问题整理。

针对单HTTP请求

  1. curl_exec($ch)在失败后,会重新创建套接字,不会复用之前的连接句柄;
  2. curl_exec执行,在底层是调用了libcurl库的curl_easy_perform方法 2curl_easy_perform以阻塞的方式执行整个请求,并在完成或失败时返回,,curl_multi_perform支持非阻塞的poll/select模式;
  3. curl_setopt设置的属性:
    • CURLOPT_CONNECTTIMEOUT:连接等待的秒数,不包含在CURLOPT_TIMEOUT或者CURLOPT_TIMEOUT_MS
    • CURLOPT_TIMEOUT_MSCURLOPT_TIMEOUT只针对单次curl_exec($ch)有用,在循环中会重新计算
  4. 在下面的示例中,while循环是否会对服务器性能有影响,是依赖于curl请求的服务(不要一看到while循环就主观认为会很消耗cpu),实际上消耗没有想象的大,尤其是在请求的web服务存不是很快响应的时候,此时curl_exec底层通过调用curl_easy_perform同步阻塞,导致while循环在等待IO响应(poll执行的过程,每秒一次)。若服务端很快响应,比如10ms内,同时while的业务也执行很快,这内容情况就比较容易导致快速循环迭代,不断请求服务,快速处理,在请求服务的过程,导致CPU的使用率会有一定上升,若我们又不希望这样(因为可能服务每次都是重复的信息,比如重复的探测),这类情况可以适当增加延迟处理来延缓循环迭代的速度。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
<?php
$ch = curl_init("http://localhost:2334/curl/req_time/".time());
while(true) {
    // 设置 URL 和相应的选项,其中TIMEOUT是在每次执行后,会重新计算
    curl_setopt($ch, CURLOPT_HEADER, 0);
    curl_setopt($ch, CURLOPT_TIMEOUT, 5);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);

    // 另外底层会以poll/select模式检测文件描述符状态(同步阻塞)
    $rs = curl_exec($ch);
    if ($rs == false) {
        echo curl_error($ch)."\n";
    } else {
        echo $rs;
    }
    // 传输信息
    $info = curl_getinfo($ch);
    printf("LocalAddress: %s:%s\n", $info["local_ip"], $info["local_port"]);
}
// 关闭 cURL 资源,并且释放系统资源
curl_close($ch);

针对多HTTP请求

  1. PHP中的curl_multi_中每个ch句柄都是一个连接套接字/句柄,curl_multi是建立在多TCP通路上发送数据(其实类似TCP/1x的多TCP并行加速下载优化,HTTP/2采用了连接复用,基于流+帧来优化,开销更小)
  2. curl_multi_perform函数以非阻塞方式处理所有需要注意的添加的句柄上的传输。
  3. PHP中的curl_multi_select底层是基于libcurl的curl_multi_wait3,轮询多句柄中的所有简单句柄,curl_multi_wait轮询给定多句柄集中包含的curl easy句柄使用的所有文件描述符。同时它将阻塞,直到在至少一个句柄上检测到活动或timeout_ms超时。在PHP的扩展中,在curl版本大于7.28.0会采用该poll模式,同时我们也发现wait的timeout是1s.

curl_multi_exec的PHP源码

CURLMcode curl_multi_perform(CURLM *multi_handle, int *running_handles);

curl_multi_exec内部是基于libcurl库函数curl_multi_perform执行,该函数以非阻塞方式处理所有需要注意的添加的句柄上的传输。

curl_multi_perform在读取/写入完成后立即返回,当应用程序发现有可用于multi_handle的数据或超时时,应用程序应调用此函数以读取/写入当前要读取或写入的内容,同时在在第二个参数的整数指针中写入仍在传输数据的句柄数。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
/* {{{ proto int curl_multi_exec(resource mh, int &still_running)
   Run the sub-connections of the current cURL handle */
PHP_FUNCTION(curl_multi_exec)
{
    zval      *z_mh;
    zval      *z_still_running;
    php_curlm *mh;
    int        still_running;
    CURLMcode error = CURLM_OK;

    ZEND_PARSE_PARAMETERS_START(2, 2)
        Z_PARAM_RESOURCE(z_mh)
        Z_PARAM_ZVAL(z_still_running)
    ZEND_PARSE_PARAMETERS_END();

    if ((mh = (php_curlm *)zend_fetch_resource(Z_RES_P(z_mh), le_curl_multi_handle_name, le_curl_multi_handle)) == NULL) {
        return;
    }
    {
        zend_llist_position pos;
        php_curl *ch;
        zval    *pz_ch;
        // 遍历每一个之前添加的easyhandler句柄,检测句柄
        for (pz_ch = (zval *)zend_llist_get_first_ex(&mh->easyh, &pos); pz_ch;
            pz_ch = (zval *)zend_llist_get_next_ex(&mh->easyh, &pos)) {
            if ((ch = (php_curl *)zend_fetch_resource(Z_RES_P(pz_ch), le_curl_name, le_curl)) == NULL) {
                return;
            }
            // 检验句柄是否有错(1表示通过`php_error_docref`报告警告错误)
            _php_curl_verify_handlers(ch, 1);
        }
    }
    still_running = zval_get_long(z_still_running);
    // 基于curl_multi_perform库函数执行
    error = curl_multi_perform(mh->multi, &still_running);
    ZEND_TRY_ASSIGN_REF_LONG(z_still_running, still_running);
    SAVE_CURLM_ERROR(mh, error);
    RETURN_LONG((zend_long) error);
}
/* }}} */

若执行curl的进程设置了TIMEOUT超时时间,同时服务器的对应的响应服务也很慢,此时curl_easy_perform内部会进行poll轮询,检测可读事件或者直至设置的TIMEOUT超时过期;该类情况通过strace可以跟踪到具体进程的内容(strace -TCvf -s 1024 -e all -p $(ps -ef|grep cur[l]|awk '{print $2}')):

1
2
3
4
5
6
7
8
9
poll([{fd=3, events=POLLIN|POLLPRI|POLLRDNORM|POLLRDBAND}], 1, 0) = 0 (Timeout) <0.000019>
poll([{fd=3, events=POLLIN|POLLPRI|POLLRDNORM|POLLRDBAND}], 1, 1000) = 0 (Timeout) <1.000522>
poll([{fd=3, events=POLLIN|POLLPRI|POLLRDNORM|POLLRDBAND}], 1, 0) = 0 (Timeout) <0.000008>
poll([{fd=3, events=POLLIN|POLLPRI|POLLRDNORM|POLLRDBAND}], 1, 1000) = 0 (Timeout) <1.001108>
poll([{fd=3, events=POLLIN|POLLPRI|POLLRDNORM|POLLRDBAND}], 1, 0) = 0 (Timeout) <0.000014>
poll([{fd=3, events=POLLIN|POLLPRI|POLLRDNORM|POLLRDBAND}], 1, 1000) = 0 (Timeout) <1.001173>
poll([{fd=3, events=POLLIN|POLLPRI|POLLRDNORM|POLLRDBAND}], 1, 0) = 0 (Timeout) <0.000013>
poll([{fd=3, events=POLLIN|POLLPRI|POLLRDNORM|POLLRDBAND}], 1, 1000) = 0 (Timeout) <1.000411>
poll([{fd=3, events=POLLIN|POLLPRI|POLLRDNORM|POLLRDBAND}], 1, 0) = 0 (Timeout) <0.000013>

curl_multi_select的PHP源码

curl_multi_wait基于poll模式,轮询给定多句柄集中包含的curl easy句柄使用的所有文件描述符,它将阻塞,直到在至少一个句柄上检测到活动或timeout_ms已经过去

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
/* {{{ proto int curl_multi_select(resource mh[, double timeout])
   Get all the sockets associated with the cURL extension, which can then be "selected" */
PHP_FUNCTION(curl_multi_select)
{
    zval           *z_mh;
    php_curlm      *mh;
    double          timeout = 1.0;
#if LIBCURL_VERSION_NUM >= 0x071c00 /* Available since 7.28.0 */
    int             numfds = 0;
#else
    fd_set          readfds;
    fd_set          writefds;
    fd_set          exceptfds;
    int             maxfd;
    struct timeval  to;
#endif
    CURLMcode error = CURLM_OK;
    ZEND_PARSE_PARAMETERS_START(1,2)
        Z_PARAM_RESOURCE(z_mh)
        Z_PARAM_OPTIONAL
        Z_PARAM_DOUBLE(timeout)
    ZEND_PARSE_PARAMETERS_END();
    if ((mh = (php_curlm *)zend_fetch_resource(Z_RES_P(z_mh), le_curl_multi_handle_name, le_curl_multi_handle)) == NULL) {
        return;
    }
#if LIBCURL_VERSION_NUM >= 0x071c00 /* Available since 7.28.0 */
    // 7.28之后的LibCurl支持`curl_multi_wait`,poll轮询多句柄中的所有简单句柄,阻塞直至任意简单句柄事件发生或超时,优于select模式1024文件描述符限制
    error = curl_multi_wait(mh->multi, NULL, 0, (unsigned long) (timeout * 1000.0), &numfds);
    if (CURLM_OK != error) {
        SAVE_CURLM_ERROR(mh, error);
        RETURN_LONG(-1);
    }
    RETURN_LONG(numfds);
#else
    _make_timeval_struct(&to, timeout);
    FD_ZERO(&readfds);
    FD_ZERO(&writefds);
    FD_ZERO(&exceptfds);
    // 从多句柄中提取文件描述符信息,添加至select函数(read_fd_set!=null:读就绪、write_fd_set!=null:写就绪、exc_fd_set!=null:异常错误)
    error = curl_multi_fdset(mh->multi, &readfds, &writefds, &exceptfds, &maxfd);
    SAVE_CURLM_ERROR(mh, error);
    if (maxfd == -1) {
        RETURN_LONG(-1);
    }
    // 基于select模式,有最大maxfd监听限制,当读、写、异常错误在任一事件产生时候返回
    RETURN_LONG(select(maxfd + 1, &readfds, &writefds, &exceptfds, &to));
#endif
}
/* }}} */

CURL MULTI代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
<?php
function showCurlInfo($chs)
{
    // 传输信息
    echo "LocalAddress ";
    foreach ($chs as $k => $ch) {
        $info = curl_getinfo($ch);
        printf("(%s:%s) ", $info["local_ip"], $info["local_port"]);
    }
    echo "\n";
}

// create both cURL resources
$ch1 = curl_init();
$ch2 = curl_init();

// set URL and other appropriate options
curl_setopt_array($ch1, [
    CURLOPT_URL            => "http://10.37.5.249:2334/multi01",
    CURLOPT_HEADER         => 0,
    CURLOPT_RETURNTRANSFER => 1,
    CURLOPT_TIMEOUT_MS     => 500,
]);
curl_setopt($ch2, CURLOPT_URL, "http://10.37.5.249:2334/multi02");
curl_setopt($ch2, CURLOPT_HEADER, 0);

//create the multiple cURL handle
$mh = curl_multi_init();

//add the two handles
$rs = curl_multi_add_handle($mh, $ch1);
$rs = curl_multi_add_handle($mh, $ch2);

// 执行批处理句柄,curl_multi_exec该函数仅返回关于整个批处理栈相关的错误。即使返回 CURLM_OK 时单个传输仍可能有问题。
// Before version 7.20.0: If you receive CURLM_CALL_MULTI_PERFORM, this basically means that you should
// call curl_multi_perform again, before you select() on more actions
$active = null;
do {
    $mrc = curl_multi_exec($mh, $active);
} while ($mrc == CURLM_CALL_MULTI_PERFORM);

// 仍在执行的标识的引用有在运行
while ($active && $mrc == CURLM_OK) {
    echo date_format(date_create(),"H:i:s u")."\n";
    // select等待事件OK, 成功时返回描述符集合中描述符的数量。
    if (curl_multi_select($mh) != -1) {
        do {
            $mrc = curl_multi_exec($mh, $active);
        } while ($mrc == CURLM_CALL_MULTI_PERFORM);
    }
//    showCurlInfo([$ch1, $ch2]);
}

$errors = curl_multi_errno($mh);
var_dump("multi error:",$errors);

// get content
foreach ([$ch1, $ch2] as $ch) {
    var_dump(curl_multi_getcontent($ch));
    curl_multi_remove_handle($mh, $ch);
}

//close the handles
curl_multi_close($mh);

Http Web服务调试

缓冲推送在IDE中看不到效果(可以直接通过curl -i请求可以查看到推送10次内容)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package main

import (
    "fmt"
    "log"
    "math/rand"
    "net/http"
    "time"
)

// 用于调试PHP curl_multi_select
func main() {
    // 可以调整time.sleep查看不同差异
    http.HandleFunc("/curl/", func(w http.ResponseWriter, r *http.Request) {
        now := time.Now()
        time.Sleep(100*time.Millisecond)
        //time.Sleep(*time.Second)
        fmt.Fprintf(w,"URI:%s,TIME ESAPSED:%s\n", r.RequestURI, time.Since(now))
    })

    // 基于flush推
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        f, _ := w.(http.Flusher)
        //w.Header().Set("x-request-id", "ok")
        for i := 0; i < 10; i++ {
            log.Printf("URI:%s, RemoteAddr:%v\n", r.RequestURI, r.RemoteAddr)
            now := time.Now()
            time.Sleep(time.Duration(rand.Intn(10)+1) * 300 * time.Millisecond)
            fmt.Fprintf(w,"URI:%s,TIME ESAPSED:%s\n", r.RequestURI, time.Since(now))
            f.Flush()
        }
    })

    log.Fatal(http.ListenAndServe(":2334", nil))
}

相关服务调试和观测命令

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// 查看推流
curl -i http://10.37.5.249:2334/multi01

// 执行php命令
php curl01.php
php curl_multi01.php

// strace跟踪php执行的curl代码进程
strace -ttfCv -s 1024 -p $(ps -ef|grep cur[l]|awk '{print $2}')

// watch查看命令TCP连接状态变化
watch -n 0.5 'netstat -ntp|grep 2334'

// 资源使用查看(pidstat、vmstat)
pidstat -p $(ps -ef|grep cur[l]|awk '{print $2}') 1

// 另外,在中间意外网络问题,通过tcpdump导出分析
tcpdump 'tcp port 2334' -w /tmp/tcpdump
tcpdump -r /tmp/tcpdump -nnn|tail

小结

基于对php的curl扩展以及libcurl包的简单使用和分析调试,在针对单独curl_exec执行时候,调用的进程会同步阻塞,直至curl_setopt设置的TIMEOUT到期或者有接收到数据;curl_multi_exec在使用时候,通过将简单句柄加入进来,同时基于pool或select的非阻塞IO模式进行事件监听,每个ch句柄都是一个单独的tcp连接;curl_exec即使是在while死循环中,也不会一定会引起系统负的高负载问题;