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

佐手、 提交于 2020-12-31 06:04:45


前言:最近在研究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的使用。


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, &onsizeof(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的一段相关代码。


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

这是设置阈值的代码。

这是超时时判断是否断开连接的代码。我们看到有两个情况下操作系统会认为连接断开了。
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;
}

执行结果


再看一下wireshark下的keepalive包

本文分享自微信公众号 - 编程杂技(theanarkh)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!