41

通过源码理解http层和tcp层的keep-alive

 3 years ago
source link: http://mp.weixin.qq.com/s?__biz=MzI3NzA5MzUxNA%3D%3D&%3Bmid=2664608356&%3Bidx=1&%3Bsn=b7e1c042a81967c77e2db6e2f88ae8a6
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

前言:最近在研究websocket和keep-alive。而websocket涉及到长连接,过多无用的长连接对系统来说是负担,是否可以尽快发现对端是否已经掉线,从而释放这个连接来减少系统压力呢,就这个初衷,通过wireshark和nodejs调试一下心跳机制。引发了一些研究和思考。

我们知道建立tcp连接的代价是比较昂贵的,每次连接需要三次握手,还有慢开始,或者建立一个连接只为了传少量数据等都影响了效率。这时候如果能保持连接,那会大大提高效率。但是如果一直保持连接,又没有数据或者有一端已经断网则会浪费资源,如何做到一个相对的取舍呢?本文通过源码来分析一下关于keep-alive的原理。读完本文,相信你会对这个问题有更多的理解。本文分成两个部分

1 http层的keep-alive 

2 tcp层的keep-alive

1 http层的keep-alive

最近恰好在看nginx1.17.9,我们就通过nginx来分析。我们先来看一下nginx的配置。

keepalive_timeout timeout;
keepalive_requests number;

上面两个参数告诉nginx,如果客户端设置了connection:keep-alive头。nginx会保持这个连接多久,另外nginx还支持另外一个限制,就是这个长连接上最多可以处理多少个请求。达到阈值后就断开连接。我们首先从nginx解析http报文开始。

第一步 读取和解析出connection头

ngx_http_request.c

ngx_http_read_request_header(r);
// 解析http请求行,r->header_in的内容由ngx_http_read_request_header设置
rc = ngx_http_parse_request_line(r, r->header_in);

ngx_http_read_request_header函数会读取一个http头的内容。然后保存到r->header_in。然后ngx_http_parse_request_line负责解析这个http头,这里我们假设解析到

{
    key: 'connection',
    value: 'keep-alive',
}

第二步 处理connection头

ngx_http_header_t  ngx_http_headers_in[] = {
    { 
        ngx_string("Connection"), 
        offsetof(ngx_http_headers_in_t, connection),
        ngx_http_process_connection 
    }
    ...
},
static void ngx_http_process_request_headers(ngx_event_t *rev) {
    hh = ngx_hash_find(
    &cmcf->headers_in_hash, 
    h->hash, 
    h->lowcase_key, 
    h->key.len);

    if (hh && hh->handler(r, h, hh->offset) != NGX_OK) {
         break;
     }
}

上面的代码大致就是根据刚才解析到的Connection:keep-alive字符串,通过Connection为key从ngx_http_headers_in数组中找到对应的处理函数。然后执行。我们看看ngx_http_process_connection 。

static ngx_int_t
ngx_http_process_connection(ngx_http_request_t *r, ngx_table_elt_t *h,
    ngx_uint_t offset)
{
    if (ngx_strcasestrn(h->value.data, "close", 5 - 1)) {
        r->headers_in.connection_type = NGX_HTTP_CONNECTION_CLOSE;

    } else if (
    ngx_strcasestrn(h->value.data, "keep-alive", 10 - 1)) {
        r->headers_in.connection_type = NGX_HTTP_CONNECTION_KEEP_ALIVE;
    }

    return NGX_OK;
}

非常简单,就是判断value的值是什么,我们假设这里是keep-alive,那么nginx会设置connection_type为NGX_HTTP_CONNECTION_KEEP_ALIVE。

第三步 进一步处理

nginx处理完http头后,接着调用ngx_http_process_request函数,该函数会调用ngx_http_handler函数。

void
ngx_http_handler(ngx_http_request_t *r) {
     switch (r->headers_in.connection_type) {
        case 0:
            r->keepalive = (r->http_version > NGX_HTTP_VERSION_10);
            break;

        case NGX_HTTP_CONNECTION_CLOSE:
            r->keepalive = 0;
            break;

        case NGX_HTTP_CONNECTION_KEEP_ALIVE:
            r->keepalive = 1;
            break;
        }
}

我们看到这时候connection_type的值是NGX_HTTP_CONNECTION_KEEP_ALIVE,nginx会设置keepalive字段为1。至此,协议头解析完毕。

看完设置,我们看什么时候会使用这个字段。我们看nginx处理完一个http请求后的流程。

第一步 处理完一个请求后设置连接的超时时间

下面调用ngx_http_finalize_connection关闭连接时的逻辑。

if (!ngx_terminate
         && !ngx_exiting
         && r->keepalive
         && clcf->keepalive_timeout > 0)
    {
        ngx_http_set_keepalive(r);
        return;
    }

我们知道这时候r->keepalive是1,clcf->keepalive_timeout就是文章开头提到的nginx配置的。接着进入ngx_http_set_keepalive。

// 超时回调
rev->handler = ngx_http_keepalive_handler;
// 启动一个定时器
ngx_add_timer(rev, clcf->keepalive_timeout);

nginx会设置一个定时器,过期时间是clcf->keepalive_timeout。过期后回调函数是ngx_http_keepalive_handler。

第二步 超时回调

static void
ngx_http_keepalive_handler(ngx_event_t *rev) {
    if (rev->timedout || c->close) {
        ngx_http_close_connection(c);
        return;
    }
}

我们看到nginx会通过ngx_http_close_connection关闭请求。这就是nginx中关于keep-alive的逻辑。

2 tcp中的keep-alive

相比应用层的长连接,tcp层提供的功能更多。tcp层定义了三个配置。

1 多久没有收到数据包,则开始发送探测包。

2 开始发送,探测包之前,如果还是没有收到数据(这里指的是有效数据,因为对端会回复ack给探测包),每隔多久,再次发送探测包。

3 发送多少个探测包后,就断开连接。

我们看linux内核代码里提供的配置。

// 多久没有收到数据就发起探测包
#define TCP_KEEPALIVE_TIME    (120*60*HZ) /* two hours */
// 探测次数
#define TCP_KEEPALIVE_PROBES    9       /* Max of 9 keepalive probes    */
// 没隔多久探测一次
#define TCP_KEEPALIVE_INTVL    (75*HZ)

这是linux提供的默认值。下面再看看阈值

#define MAX_TCP_KEEPIDLE    32767
#define MAX_TCP_KEEPINTVL    32767
#define MAX_TCP_KEEPCNT        127

这三个配置和上面三个一一对应。是上面三个配置的阈值。我们通过nodejs的keep-alive分析这个原理。我们先看一下nodejs中keep-alive的使用。

640?wx_fmt=png

enable:是否开启keep-alive,linux下默认是不开启的。

initialDelay:多久没有收到数据包就开始发送探测包。

接着我们看看这个api在libuv中的实现。

int uv__tcp_keepalive(int fd, int on, unsigned int delay) {
  if (setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &on, sizeof(on)))
    return UV__ERR(errno);
// linux定义了这个宏
#ifdef TCP_KEEPIDLE
  /*
      on是1才会设置,所以如果我们先开启keep-alive,并且设置delay,
      然后关闭keep-alive的时候,是不会修改之前修改过的配置的。
      因为这个配置在keep-alive关闭的时候是没用的
  */
  if (on && setsockopt(fd, IPPROTO_TCP, TCP_KEEPIDLE, &delay, sizeof(delay)))
    return UV__ERR(errno);
#endif

  return 0;
}

我们看到libuv调用了同一个系统函数两次。我们分别看一下这个函数的意义。参考linux2.6.13.1的代码。

// net/socket.c
asmlinkage long sys_setsockopt(int fd, int level, int optname, char __user *optval, int optlen)
{
    int err;
    struct socket *sock;

    if ((sock = sockfd_lookup(fd, &err))!=NULL)
    {
        ...
        if (level == SOL_SOCKET)
            err=sock_setsockopt(
            sock,
            level,
            optname,
            optval,
            optlen);
        else
            err=sock->ops->setsockopt(sock, 
            level, 
            optname, 
            optval, 
            optlen);
        sockfd_put(sock);
    }
    return err;
}

当level是SOL_SOCKET代表修改的socket层面的配置。IPPROTO_TCP是修改tcp层的配置(该版本代码里是SOL_TCP)。我们先看SOL_SOCKET层面的。

// net/socket.c -> net/core/sock.c -> net/ipv4/tcp_timer.c
int sock_setsockopt(struct socket *sock, int level, int optname,
            char __user *optval, int optlen) {
    ...
    case SO_KEEPALIVE:

            if (sk->sk_protocol == IPPROTO_TCP)
                tcp_set_keepalive(sk, valbool);
            // 设置SOCK_KEEPOPEN标记位1
            sock_valbool_flag(sk, SOCK_KEEPOPEN, valbool);
            break;
    ...
}

sock_setcsockopt主要做了两个事情

1 给对应socket的SOCK_KEEPOPEN字段打上标记(0或者1表示开启还是关闭)

2 调用tcp_set_keepalive函数启动一个定时器。

接下来我们看tcp_set_keepalive

void tcp_set_keepalive(struct sock *sk, int val)
{
    if ((1 << sk->sk_state) & (TCPF_CLOSE | TCPF_LISTEN))
        return;
    /*
        如果val是1并且之前是0(没开启)那么就开启计时,超时后发送探测包,
        如果之前是1,val又是1,则忽略,所以重复设置是无害的
    */
    if (val && !sock_flag(sk, SOCK_KEEPOPEN))
        tcp_reset_keepalive_timer(sk,keepalive_time_when(tcp_sk(sk)));
    else if (!val)
        // val是0表示关闭,则清除定时器,就不发送探测包了
        tcp_delete_keepalive_timer(sk);
}

我们看看超时后的逻辑。

// 多久没有收到数据包则发送第一个探测包
static inline int keepalive_time_when(const struct tcp_sock *tp)
{
    // 用户设置的(TCP_KEEPIDLE)和系统默认的
    return tp->keepalive_time ? : sysctl_tcp_keepalive_time;
}
// 隔多久发送一个探测包
static inline int keepalive_intvl_when(const struct tcp_sock *tp)
{
    return tp->keepalive_intvl ? : sysctl_tcp_keepalive_intvl;
}

static void tcp_keepalive_timer (unsigned long data)
{
...
// 多久没有收到数据包了
elapsed = tcp_time_stamp - tp->rcv_tstamp;
    // 是否超过了阈值
    if (elapsed >= keepalive_time_when(tp)) {
        // 发送的探测包个数达到阈值,发送重置包
        if ((!tp->keepalive_probes && 
        tp->probes_out >= sysctl_tcp_keepalive_probes) ||
             (tp->keepalive_probes && 
             tp->probes_out >= tp->keepalive_probes)) {
            tcp_send_active_reset(sk, GFP_ATOMIC);
            tcp_write_err(sk);
            goto out;
        }
        // 发送探测包,并计算下一个探测包的发送时间(超时时间)
        tcp_write_wakeup(sk)
            tp->probes_out++;
            elapsed = keepalive_intvl_when(tp);
    } else {
        /*
            还没到期则重新计算到期时间,收到数据包的时候应该会重置定时器,
            所以执行该函数说明的确是超时了,按理说不会进入这里。
        */
        elapsed = keepalive_time_when(tp) - elapsed;
    }

    TCP_CHECK_TIMER(sk);
    sk_stream_mem_reclaim(sk);

resched:
    // 重新设置定时器
    tcp_reset_keepalive_timer (sk, elapsed);
...

所以在SOL_SOCKET层面是设置是否开启keep-alive机制。如果开启了,就会设置定时器,超时的时候就会发送探测包。我们发现,SOL_SOCKET只是设置了是否开启探测机制,并没有定义上面三个配置的值,所以系统会使用默认值进行心跳机制(如果我们设置了开启keep-alive的话)。这就是为什么libuv调了两次setsockopt函数。第二次的调用设置了就是上面三个配置中的第一个(后面两个也可以设置,不过libuv没有提供接口,可以自己调用setsockopt设置)。那么我们来看一下libuv的第二次调用setsockopt是做了什么。我们直接看tcp层的实现。

// net\ipv4\tcp.c
int tcp_setsockopt(struct sock *sk, int level, int optname, char __user *optval,int optlen)
{
    ...
    case TCP_KEEPIDLE:
        // 修改多久没有收到数据包则发送探测包的配置
        tp->keepalive_time = val * HZ;
            // 是否开启了keep-alive机制
            if (sock_flag(sk, SOCK_KEEPOPEN) &&
                !((1 << sk->sk_state) &
                  (TCPF_CLOSE | TCPF_LISTEN))) {
                // 当前时间减去上次收到数据包的时候,即多久没有收到数据包了
                __u32 elapsed = tcp_time_stamp - tp->rcv_tstamp;
                // 算出还要多久可以发送探测包,还是可以直接发(已经触发了)
                if (tp->keepalive_time > elapsed)
                    elapsed = tp->keepalive_time - elapsed;
                else
                    elapsed = 0;
                // 设置定时器
                tcp_reset_keepalive_timer(sk, elapsed);
            }   
        ...
}

该函数首先修改配置,然后判断是否开启了keep-alive的机制,如果开启了,则重新设置定时器,超时的时候就会发送探测包。

但是有一个问题是,心跳机制并不是什么时候都好使,如果两端都没有数据来往时,心跳机制能很好地工作,但是一旦本端有数据发送的时候,他就会抑制心跳机制。我们看一下linux内核5.7.7的一段相关代码。

640?wx_fmt=png

上面这一段是心跳机制中,定时器超时时,执行的一段逻辑,我们只需要关注红色框里的代码。一般来说,心跳定时器超时,操作系统会发送一个新的心跳包,但是如果发送队列里还有数据没有发送,那么操作系统会优先发送。或者发送出去的没有ack,也会优先触发重传。这时候心跳机制就失效了。对于这个问题,linux提供了另一个属性TCP_USER_TIMEOUT。这个属性的功能是,发送了数据,多久没有收到ack后,操作系统就认为这个连接断开了。看一下相关代码。

640?wx_fmt=png

这是设置阈值的代码。

640?wx_fmt=png

这是超时时判断是否断开连接的代码。我们看到有两个情况下操作系统会认为连接断开了。

1 设置了TCP_USER_TIMEOUT时,如果发送包数量大于1并且当前时间距离上次收到包的时间间隔已经达到阈值。

2 没有设置TCP_USER_TIMEOUT,但是心跳包发送数量达到阈值。

所以我们可以同时设置这两个属性。保证心跳机制可以正常运行(但是nodejs只支持TCP_KEEPALIVE_TIME,对于这个问题的改进,已经尝试给nodejs提了pr  

https://github.com/nodejs/node/pull/34193

)。

最后我们看看linux下的默认配置。

include <stdio.h>
#include <netinet/tcp.h>     

int main(int argc, const char *argv[])
{
    int sockfd;
    int optval;
    socklen_t optlen = sizeof(optval);

    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    getsockopt(sockfd, SOL_SOCKET, SO_KEEPALIVE, &optval, &optlen);
    printf("默认是否开启keep-alive:%d \n", optval);

    getsockopt(sockfd, SOL_TCP, TCP_KEEPIDLE, &optval, &optlen);
    printf("多久没有收到数据包则发送探测包:%d seconds \n", optval);

    getsockopt(sockfd, SOL_TCP, TCP_KEEPINTVL, &optval, &optlen);
    printf("多久发送一次探测包:%d seconds \n", optval);

    getsockopt(sockfd, SOL_TCP, TCP_KEEPCNT, &optval, &optlen);
    printf("最多发送几个探测包就断开连接:%d \n", optval);

    return 0;
}

执行结果

640?wx_fmt=png

再看一下wireshark下的keepalive包

640?wx_fmt=png

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK