[입 개발] IPv4 TCP Socket, Listen 에서 Accept 까지…

갑자기 초괴수 지인분이 TCP Socket 에서 Listen 하고 Accept 할 때 어떤 일이 벌어지는지에 대해서 궁금해 하시는 질문을 올리셨습니다. 사실 Accept 자체는 별로 하는게 없다라는 건 알고 있었는데, 실제로 그 사이에 어떤 일이 벌어지는지에 대해서는 저도 잘 모르고 있어서, 그냥 한번 살펴봤습니다. 먼저, 이걸 보기 전에 TCP의 Connection이 맺어지는 3-way handshake는 굉장히 유명하고 중요하니, 이미지를 도용해옵시다.

3whs

일단 위의 그림을 보면 client 가 connect 를 하기 전에 server 는 listen을 해둬야 합니다. 그럼 이 listen을 하는 동안 어떤 작업이 일어나게 될까요?(linux 4.12.2 기준입니다.)

먼저 봐야할 소스는 net/ipv4/af_inet.c 의 inet_stream_ops 설정입니다. 실제 c코드 등의 함수가 커널레벨에서는 이 함수들과 매핑이 된다고 보시면 됩니다. 여기서 listen 은 inet_listen, accept 은 inet_accept 이 설정되어 있는 것을 볼 수 있습니다.

const struct proto_ops inet_stream_ops = {
    .family        = PF_INET,
    .owner         = THIS_MODULE,
    .release       = inet_release,
    .bind          = inet_bind,
    .connect       = inet_stream_connect,
    .socketpair    = sock_no_socketpair,
    .accept        = inet_accept,
    .getname       = inet_getname,
    .poll          = tcp_poll,
    .ioctl         = inet_ioctl,
    .listen        = inet_listen,
    .shutdown      = inet_shutdown,
    .setsockopt    = sock_common_setsockopt,
    .getsockopt    = sock_common_getsockopt,
    .sendmsg       = inet_sendmsg,
    .recvmsg       = inet_recvmsg,
    .mmap          = sock_no_mmap,
    .sendpage      = inet_sendpage,
    .splice_read       = tcp_splice_read,
    .read_sock     = tcp_read_sock,
    .peek_len      = tcp_peek_len,
#ifdef CONFIG_COMPAT
    .compat_setsockopt = compat_sock_common_setsockopt,
    .compat_getsockopt = compat_sock_common_getsockopt,
    .compat_ioctl      = inet_compat_ioctl,
#endif
};

그럼 이제 inet_listen 함수를 찾아봅니다. 코드를 보면 TCP_FASTOPEN 에 대한 처리도 있는데, 이 부분은 일단은 생략합니다. inet_listen 함수에서는 해당 socket 이 TCP_LISTEN 상태가 아니면 inet_csk_listen_start 함수를 호출하고 listen의 파라매터로 넘어오는 backlog 를 설정합니다.

/*
 *  Move a socket into listening state.
 */
int inet_listen(struct socket *sock, int backlog)
{
    struct sock *sk = sock->sk;
    unsigned char old_state;
    int err;

    lock_sock(sk);

    err = -EINVAL;
    if (sock->state != SS_UNCONNECTED || sock->type != SOCK_STREAM)
        goto out;

    old_state = sk->sk_state;
    if (!((1 << old_state) & (TCPF_CLOSE | TCPF_LISTEN)))
        goto out;

    /* Really, if the socket is already in listen state
     * we can only allow the backlog to be adjusted.
     */
    if (old_state != TCP_LISTEN) {
        /* Enable TFO w/o requiring TCP_FASTOPEN socket option.
         * Note that only TCP sockets (SOCK_STREAM) will reach here.
         * Also fastopen backlog may already been set via the option
         * because the socket was in TCP_LISTEN state previously but
         * was shutdown() rather than close().
         */
        if ((sysctl_tcp_fastopen & TFO_SERVER_WO_SOCKOPT1) &&
            (sysctl_tcp_fastopen & TFO_SERVER_ENABLE) &&
            !inet_csk(sk)->icsk_accept_queue.fastopenq.max_qlen) {
            fastopen_queue_tune(sk, backlog);
            tcp_fastopen_init_key_once(true);
        }

        err = inet_csk_listen_start(sk, backlog);
        if (err)
            goto out;
    }
    sk->sk_max_ack_backlog = backlog;
    err = 0;

out:
    release_sock(sk);
    return err;
}

그럼 다시 inet_csk_listen_start 함수를 살펴봅니다 net/ipv4/inet_connection_sock.c 안에 있습니다. inet_csk_listen_start 함수에서 처음에 신경써서 볼 부분은 reqsk_queue_alloc 함수를 호출하는 부분입니다. 변수명이 뭔가 와 닫는가요? icsk_accept_queue 라는 이름으로 할당하고 있습니다. 네, 이것이 바로 TCP에서 실제 connect 하는 client 에 대한 연결 요청이 저장되는 queue 입니다. accept 에서는 여기에 있으면 바로 가져가고, 없으면 대기하게 되는거죠. 여기서 해당 포트를 확보하는데 문제가 발생하면 TCP_CLOSE 상태로 가게됩니다.

int inet_csk_listen_start(struct sock *sk, int backlog)
{
    struct inet_connection_sock *icsk = inet_csk(sk);
    struct inet_sock *inet = inet_sk(sk);
    int err = -EADDRINUSE;

    reqsk_queue_alloc(&icsk->icsk_accept_queue);

    sk->sk_max_ack_backlog = backlog;
    sk->sk_ack_backlog = 0;
    inet_csk_delack_init(sk);

    /* There is race window here: we announce ourselves listening,
     * but this transition is still not validated by get_port().
     * It is OK, because this socket enters to hash table only
     * after validation is complete.
     */
    sk_state_store(sk, TCP_LISTEN);
    if (!sk->sk_prot->get_port(sk, inet->inet_num)) {
        inet->inet_sport = htons(inet->inet_num);

        sk_dst_reset(sk);
        err = sk->sk_prot->hash(sk);

        if (likely(!err))
            return 0;
    }

    sk->sk_state = TCP_CLOSE;
    return err;
}

이제 해당 socket 이 TCP_LISTEN 상태가 되었습니다. 그런데 젤 앞에 TCP 3-way handshake는 client 가 connect 함수를 호출하면서 SYN 패킷을 보내면서 부터 시작되게 됩니다. 이 부분은 어디서 처리하게 될까요? tcp_rcv_state_process 라는 함수가 net/ipv4/tcp_input.c 에 있습니다. 그런데 이 함수는 어디서 호출되는 것일까요? 다음과 같이 tcp_protocol 정의를 보면 실제 데이터를 처리하는 tcp_v4_rcv 라는 함수가 있습니다.

static struct net_protocol tcp_protocol = {
    .early_demux    =   tcp_v4_early_demux,
    .early_demux_handler =  tcp_v4_early_demux,
    .handler    =   tcp_v4_rcv,
    .err_handler    =   tcp_v4_err,
    .no_policy  =   1,
    .netns_ok   =   1,
    .icmp_strict_tag_validation = 1,
};

해당 socket 이 TCP_LISTEN 상태이면 다시 tcp_v4_do_rcv 라는 함수를 호출하게 되고 다시 tcp_child_process 함수를 호출하거나 하지 않더라도 최종적으로 tcp_rcv_state_process 함수를 호출하게 됩니다.(tcp_child_process가 호출되지 않아도 그 밑에 tcp_rcv_state_process가 호출됩니다.) TCP_LISTEN 인 경우를 보면, 앞에 TCP 3-way handshake를 한번 더 기억해야 합니다.

SYN -> SYN+ACK -> ACK 형태의 순서로 넘어가게 되는데, SYN과 ACK가 server 쪽에서 받게 되는 패킷입니다. ACK가 오면, 이제 ESTABLISH 가 되는 것이므로 여기서는 바로 return 1을 하게 됩니다. 그러면 실제로 SYN+ACK를 보내는 상황은 SYN을 받았을 때 입니다. FIN이 설정되어 있으면, TCP 접속을 종료하는 거니 discard 하게 되고, 정상적이면 conn_request 함수를 호출하게 됩니다.

    case TCP_LISTEN:
        if (th->ack)
            return 1;

        if (th->rst)
            goto discard;

        if (th->syn) {
            if (th->fin)
                goto discard;
            /* It is possible that we process SYN packets from backlog,
             * so we need to make sure to disable BH right there.
             */
            local_bh_disable();
            acceptable = icsk->icsk_af_ops->conn_request(sk, skb) >= 0;
            local_bh_enable();

            if (!acceptable)
                return 1;
            consume_skb(skb);
            return 0;
        }
        goto discard;

conn_request 함수는 다음 ipv4_specific를 살펴봐야 합니다. tcp_v4_conn_request 함수랑 매핑이 되어 있네요.

const struct inet_connection_sock_af_ops ipv4_specific = {
    .queue_xmit    = ip_queue_xmit,
    .send_check    = tcp_v4_send_check,
    .rebuild_header    = inet_sk_rebuild_header,
    .sk_rx_dst_set     = inet_sk_rx_dst_set,
    .conn_request      = tcp_v4_conn_request,
    .syn_recv_sock     = tcp_v4_syn_recv_sock,
    .net_header_len    = sizeof(struct iphdr),
    .setsockopt    = ip_setsockopt,
    .getsockopt    = ip_getsockopt,
    .addr2sockaddr     = inet_csk_addr2sockaddr,
    .sockaddr_len      = sizeof(struct sockaddr_in),
#ifdef CONFIG_COMPAT
    .compat_setsockopt = compat_ip_setsockopt,
    .compat_getsockopt = compat_ip_getsockopt,
#endif
    .mtu_reduced       = tcp_v4_mtu_reduced,
};

tcp_v4_conn_request 는 tcp_conn_request 라는 함수를 다시 호출합니다. TCP_FASTOPEN 이 아닐 때 보면 inet_csk_reqsk_queue_hash_add 를 호출하는데 이 함수가 실제로 accept_queue에 값을 집어넣는 함수입니다. 라고 생각했는데, 소스를 잘못 본것입니다. 여기서는 TIMEOUT만 설정하게 됩니다. 그리고 SYN+ACK를 보내고 되죠.

    if (fastopen_sk) {
        af_ops->send_synack(fastopen_sk, dst, &fl, req,
                    &foc, TCP_SYNACK_FASTOPEN);
        /* Add the child socket directly into the accept queue */
        inet_csk_reqsk_queue_add(sk, req, fastopen_sk);
        sk->sk_data_ready(sk);
        bh_unlock_sock(fastopen_sk);
        sock_put(fastopen_sk);
    } else {
        tcp_rsk(req)->tfo_listener = false;
        if (!want_cookie)
            inet_csk_reqsk_queue_hash_add(sk, req, TCP_TIMEOUT_INIT);
        af_ops->send_synack(sk, dst, &fl, req, &foc,
                    !want_cookie ? TCP_SYNACK_NORMAL :
                           TCP_SYNACK_COOKIE);
        if (want_cookie) {
            reqsk_free(req);
            return 0;
        }
    }

같은 tcp_conn_request 함수안의 앞부분을 보면 inet_reqsk_alloc 을 호출하는 부분이 있습니다.

int tcp_conn_request(struct request_sock_ops *rsk_ops,
             const struct tcp_request_sock_ops *af_ops,
             struct sock *sk, struct sk_buff *skb)
{
    struct tcp_fastopen_cookie foc = { .len = -1 };
    __u32 isn = TCP_SKB_CB(skb)->tcp_tw_isn;
    struct tcp_options_received tmp_opt;
    struct tcp_sock *tp = tcp_sk(sk);
    struct net *net = sock_net(sk);
    struct sock *fastopen_sk = NULL;
    struct dst_entry *dst = NULL;
    struct request_sock *req;
    bool want_cookie = false;
    struct flowi fl;

    /* TW buckets are converted to open requests without
     * limitations, they conserve resources and peer is
     * evidently real one.
     */
    if ((net->ipv4.sysctl_tcp_syncookies == 2 ||
         inet_csk_reqsk_queue_is_full(sk)) && !isn) {
        want_cookie = tcp_syn_flood_action(sk, skb, rsk_ops->slab_name);
        if (!want_cookie)
            goto drop;
    }

    if (sk_acceptq_is_full(sk)) {
        NET_INC_STATS(sock_net(sk), LINUX_MIB_LISTENOVERFLOWS);
        goto drop;
    }

    req = inet_reqsk_alloc(rsk_ops, sk, !want_cookie);
    if (!req)
        goto drop;

    ......

inet_reqsk_alloc 함수를 보면 TCP_NEW_SYN_RECV 로 셋팅하는 부분이 있습니다. 그러면서 새로운 child 소켓을 생성하기 위한 준비를 하는 것으로 보입니다. TCP_NEW_SYN_RECV는 https://patchwork.ozlabs.org/patch/449704/ 를 보시면 왜 추가되었는지 설명이 나옵니다.(저도 잘 몰라요 ㅋㅋㅋ)

struct request_sock *inet_reqsk_alloc(const struct request_sock_ops *ops,
                      struct sock *sk_listener,
                      bool attach_listener)
{
    struct request_sock *req = reqsk_alloc(ops, sk_listener,
                           attach_listener);

    if (req) {
        struct inet_request_sock *ireq = inet_rsk(req);

        kmemcheck_annotate_bitfield(ireq, flags);
        ireq->opt = NULL;
#if IS_ENABLED(CONFIG_IPV6)
        ireq->pktopts = NULL;
#endif
        atomic64_set(&ireq->ir_cookie, 0);
        ireq->ireq_state = TCP_NEW_SYN_RECV;
        write_pnet(&ireq->ireq_net, sock_net(sk_listener));
        ireq->ireq_family = sk_listener->sk_family;
    }

    return req;
}

다시 처음의 tcp_v4_rcv 함수로 돌아갑니다.(net/ipv4/tcp_ipv4.c), 여기서 tcp_check_req 함수가 호출이 됩니다.(net/ipv4/tcp_minisocks.c)

    if (sk->sk_state == TCP_NEW_SYN_RECV) {
        struct request_sock *req = inet_reqsk(sk);
        struct sock *nsk;

        sk = req->rsk_listener;
        if (unlikely(tcp_v4_inbound_md5_hash(sk, skb))) {
            sk_drops_add(sk, skb);
            reqsk_put(req);
            goto discard_it;
        }
        if (unlikely(sk->sk_state != TCP_LISTEN)) {
            inet_csk_reqsk_queue_drop_and_put(sk, req);
            goto lookup;
        }
        /* We own a reference on the listener, increase it again
         * as we might lose it too soon.
         */
        sock_hold(sk);
        refcounted = true;
        nsk = tcp_check_req(sk, skb, req, false);
        if (!nsk) {
            reqsk_put(req);
            goto discard_and_relse;
        }
        if (nsk == sk) {
            reqsk_put(req);
        } else if (tcp_child_process(sk, nsk, skb)) {
            tcp_v4_send_reset(nsk, skb);
            goto discard_and_relse;
        } else {
            sock_put(sk);
            return 0;
        }
    }

tcp_check_req 에서는 뭔가 복잡한 작업을 하고 있습니다.(제가 이걸 보고 바로 이해할 능력은 안됩니다. 하하하하 T.T) 일단 SYN+ACK를 보내고 여기서 ACK를 받아야 정상적으로 연결이 완료되기 때문에, child 소켓이 만들어지고(accept 하면 server 소켓이 아니라 다른 소켓을 받게 되는거 기억나시죠?, tcp_v4_syn_recv_sock 함수에서 만들어집니다.) 마지막에 inet_csk_complete_hashdance 함수를 호출하면서

struct sock *tcp_check_req(struct sock *sk, struct sk_buff *skb,
               struct request_sock *req,
               bool fastopen)
{
     ......
     /* OK, ACK is valid, create big socket and
     * feed this segment to it. It will repeat all
     * the tests. THIS SEGMENT MUST MOVE SOCKET TO
     * ESTABLISHED STATE. If it will be dropped after
     * socket is created, wait for troubles.
     */
    child = inet_csk(sk)->icsk_af_ops->syn_recv_sock(sk, skb, req, NULL,
                             req, &own_req);
    if (!child)
        goto listen_overflow;

    sock_rps_save_rxhash(child, skb);
    tcp_synack_rtt_meas(child, req);
    return inet_csk_complete_hashdance(sk, child, req, own_req);

inet_csk_complete_hashdance 함수에서는 실제로 inet_csk_reqsk_queue_add 함수를 호출해서 실제로 accept_queue에 새로 생성된 child socket을 집어넣어줍니다.

struct sock *inet_csk_complete_hashdance(struct sock *sk, struct sock *child,
                     struct request_sock *req, bool own_req)
{
    if (own_req) {
        inet_csk_reqsk_queue_drop(sk, req);
        reqsk_queue_removed(&inet_csk(sk)->icsk_accept_queue, req);
        if (inet_csk_reqsk_queue_add(sk, req, child))
            return child;
    }
    /* Too bad, another child took ownership of the request, undo. */
    bh_unlock_sock(child);
    sock_put(child);
    return NULL;
}

지금까지 진행한 부분에서 새로 생성된 소켓을 TCP_ESTABLISHED 로 설정되는 부분이 안보입니다. 이건 어디서 할까요? 실제로 위에 살짝 빠지고 넘어간 tcp_v4_syn_recv_sock 함수를 살펴보면, 새로운 소켓을 만들기 위한 tcp_create_openreq_child 함수를 호출합니다. 여기서 다시 inet_csk_clone_lock 함수를 통해서 parent를 clone 하게 되는데 여기서 TCP_SYN_RECV 로 state가 설정되게 되고, 다시 tcp_rcv_state_process 에서 TCP_ESTABLISHED로 설정이 됩니다.(확실하지는 않습니다.)

이제 accept 이 호출되었을 때를 살펴보도록 하겠습니다. accept 은 처음에 inet_accept 함수를 호출하게 되고, 여기서 내부적으로 inet_csk_accept 을 호출하게 됩니다. 먼저 accept_queue를 확인해서 empty 이면, 하나도 존재하지 않으니, inet_csk_wait_for_connect 를 호출해서 내부적으로 대기하게 됩니다. O_NONBLOCK 이 설정되어 있으면 하나도 없을 때, 익숙한 -EAGAIN이 리턴됩니다.

/*
 * This will accept the next outstanding connection.
 */
struct sock *inet_csk_accept(struct sock *sk, int flags, int *err, bool kern)
{
    struct inet_connection_sock *icsk = inet_csk(sk);
    struct request_sock_queue *queue = &icsk->icsk_accept_queue;
    struct request_sock *req;
    struct sock *newsk;
    int error;

    lock_sock(sk);

    /* We need to make sure that this socket is listening,
     * and that it has something pending.
     */
    error = -EINVAL;
    if (sk->sk_state != TCP_LISTEN)
        goto out_err;

    /* Find already established connection */
    if (reqsk_queue_empty(queue)) {
        long timeo = sock_rcvtimeo(sk, flags & O_NONBLOCK);

        /* If this is a non blocking socket don't sleep */
        error = -EAGAIN;
        if (!timeo)
            goto out_err;

        error = inet_csk_wait_for_connect(sk, timeo);
        if (error)
            goto out_err;
    }
    req = reqsk_queue_remove(queue, sk);
    newsk = req->sk;

    if (sk->sk_protocol == IPPROTO_TCP &&
        tcp_rsk(req)->tfo_listener) {
        spin_lock_bh(&queue->fastopenq.lock);
        if (tcp_rsk(req)->tfo_listener) {
            /* We are still waiting for the final ACK from 3WHS
             * so can't free req now. Instead, we set req->sk to
             * NULL to signify that the child socket is taken
             * so reqsk_fastopen_remove() will free the req
             * when 3WHS finishes (or is aborted).
             */
            req->sk = NULL;
            req = NULL;
        }
        spin_unlock_bh(&queue->fastopenq.lock);
    }
out:
    release_sock(sk);
    if (req)
        reqsk_put(req);
    return newsk;
out_err:
    newsk = NULL;
    req = NULL;
    *err = error;
    goto out;
}

reqsk_queue_remove 를 호출하면 실제로 accept_queue 에서 연결을 하나 가져오게 됩니다.(링크드 리스트에서 head를 가져옵니다.)