三、深入理解connect
客户端再发起连接的时候,创建一个socket,如何瞄准服务端调用connect就可以了,代码可以简单到只有两句。
int main(){ fd = socket(AF_INET, SOCK_STREAM, 0); connect(fd, ...); }
但这两行代码背后隐藏的技术细节却很多。
1)connect调用链展开
当客户机调用connect函数的时候,进入系统调用
SYSCALL_DEFINE3(connect, int, fd, struct sockaddr __user *, uservaddr, int, addrlen) { struct socket *sock; // 根据用户fd查找内核中的socket对象 sock = sockfd_lookup_light(fd, &err, &fput_needed); // 进行connect err = sock->ops->connect(sock, (struct sockaddr *)&address, addlen, sock->file->f_flags); ...... }
同理还是首先根据用户传入的文件描述符来查询对应的socket内核对象,如何再调用sock->ops->connect,对于AF_INET类型的socket而言,指向的是inet_stream_connect。而
inet_stream_connect实际会去调用__inet_stream_connect
int __inet_stream_connect(struct socket *sock, ...) { struct sock *sk = sock->sk; witch(sock->state) { default: err = -EINVAL; goto out; case SS_CONNECTED: // 此套接口已经和对端的套接口相连接了,即连接已经建立 err = -EISCONN; goto out; case SS_CONNECTING: // 此套接口正在尝试连接对端的套接口,即连接正在建立中 err = -EALREADY; break; case SS_UNCONNECTED: err = sk->sk_prot->connect(sk, uaddr, addr_len); sock->state = SS_CONNECTING; err = -EINPROGRESS; break; } ...... }
刚创建完毕的socket的状态就是SS_UNCONNECTED,根据switch判断会去调用sk->sk_prot->connect,对于TCP socket而言,调用的是tcp_v4_connect。
int tcp_v4_connect(struct sock *sk, struct sockaddr *uaddr, int addr_len) { // 设置socket的状态为TCP_SYN_SENT tcp_set_state(sk, TCP_SYN_SENT); // 动态选择一个端口 err = inet_hash_connect(&tcp_death_row, sk); // 函数用来根据sk中的信息,构建一个syn报文,并将它发送出去 err = tcp_connect(sk); }
在这里会把socket的状态设置为TCP_SYN_SENT,再通过inet_hash_connect来动态地选择一个可用的端口。
2)选择可用端口
找到inet_hash_connect的源码,我们来看看到底端口时如何选择出来的。
int inet_hash_connect(struct inet_timewait_death_row *death_row, struct sock *sk) { return __inet_hash_connect(death_row, sk, inet_sk_port_offset(sk), __inet_check_established, __inet_hash_nolisten); }
这里需要关注一下调用__inet_hash_connect的两个参数 :
inet_sk_port_offset(sk):这个函数根据要链接的目的IP和端口等信息生成一个随机数
__inet_check_established:检查是否和现有ESTABLISH状态的连接冲突的时候用的函数
接着进入__inet_hash_connect函数
int __inet_hash_connect(...) { // 是否绑定过端口 const unsigned short snum = inet_sk(sk)->inet_num; // 获取本地端口配置 inet_get_local_port_range(&low, &high); remaing = (high - low) + 1; if(!snum) { // 遍历查找 for(int i = 1; i <= remaining; i++){ port = low + (i + offset) % remaining; // 保证了port会在范围之间 // 查看是否是保留端口,是则跳过 if(inet_is_reserverd_local_port(port)) continue; // 查找和遍历已经使用的端口的哈希表链 head = &hinfo->bhash[inet_bhashfn(net, port, hinfo->bhash_size)]; inet_bind_bucket_for_each(tb, &head->charin) { // 如果端口已经使用 if(net_eq(ib_net(tb), net) && tb->port == port) { // 通过check_established继续检查是否可用 if(!check_established(death_row, sk, port, &tw)) goto ok; } } // 未使用的话 tb = inet_bind_bukcet_create(hinfo->bind_bucket_cachep, ...); ...... goto ok; } } }
在这个函数中首先判断了inet_sk(sk)->inet_num,如果调用过bind,那么这个函数会选择好端口并设置在inet_num上,加入没有调用过bind,那么snum为0。
接着调用inet_get_local_port_range,这个函数读取的是net.ipv4.ip_local_port_range这个内核参数,来读取管理员配置的可用的端口范围。
该参数的默认值是32768-61000,意味着端口与总可用量是61000-32768=28232个。如果觉得这个数字不够用,那么可以通过修改net.ipve4.ip_local_port_range内参参数来重新设置。
接下来进入for循环,其中offset是通过inet_sk_port_offset(sk)计算出来的随机数(是调用__inet_hash_connect时传进来的参数)。这段循环的作用就是从某个随机数开始,把整个可用端口范围遍历一遍,直到找到可用的端口为止。具体逻辑如下
1.从随机数+low开始选取一个端口
2.判断端口是否是保留端口,即判断端口是否在net.ipv4.ip_local_reserved_ports中(如果因3.为某种原因不希望某些端口被内核使用则可以写入这个参数)
3.获取已使用端口的哈希表
4.遍历哈希表判断端口是否被使用,如果没有找到则说明可以使用,已使用过则调用check_established(具体逻辑见下部分)
5.找到合适的端口:通过inet_bind_bucket_create申请一个inet_bind_bucket来记录端口已经使用了,并用哈希表的形式管理起来。
6.找不到合适的端口:返回-EADDRNOTAVAIL,也就是我们在用户程序上看到的Cannot assign requested address
所以如果遇到这个错误,应该想到去查一下net.ipv4.ip_local_port_range中设置的可用端口的范围是不是太小了。
3)端口被使用过怎么办
在遍历已使用端口的哈希表时,对于已被使用的端口,会去调用check_established继续检查是否可用,如果这个函数返回0,则说明端口可以继续使用。
对于TCP连接而言,维护的是一对四元组,分别由收发双方的端口号和ip地址决定,只要四元组中任意一个元素不同,都算是两条不同的连接。所以只要现有的TCP连接中四元组不与要建立的连接的其他三个元素完全一致,该端口就仍然可以使用。
check_established实际上会去调用__inet_check_established
static int __inet_check_established(struct inet_timewait_death_row *death_row, struct sock *sk, __u16 lport, struct inet_timewait_sock **twp) { // 查找哈希桶 ehash_bucket *head = inet_ehash_buket(hinfo, hash); // 遍历看看有没有四元组一样的,一样的话就报错 sk_nulls_for_each(sk2, node, &head->chain) { if(sk2->sk_hash != hash) continue; if(likely(INET_MATCH(sk2, net, acookie, saddr, daddr, ports, dif)) goto not_unique; } unique: return 0; not_uniqueue: return -EADDRNOTAVAIL; }
该函数首先找到inet_ehash_bucket(类似bhash,只不过这是所有ESTABLISH状态的socket组成的hash表),然后遍历整个哈希表,如果哈希值不相同则说明当前四元组不一致,如果哈希值相同则使用INET_MATCH进一步进行比较。如果匹配就是说明四元组完全一致,所以这个端口不可用,返回-EADDRNOTAVAIL,如果不匹配(四元组有一或以上个元素不一样)那么就返回0,表示该端口仍然可以用于建立新连接。
INET_MATCH中除了将__saddr、__daddr、__ports进行了比较,还比较了一些其他项目,所以TCP连接还有五元组、七元组之类的说法。
一台客户机的最大建立的连接数并不是65535,只要有足够多的服务端,单机发出百万条连接没有任何问题。
4)发起SYN请求
找到可用的端口后,回到tcp_v4_connect,接下来会去调用tcp_connect来根据sk中的信息构建一个syn报文发送出去。
int tcp_connect(struct sock *sk) { // 申请并设置skb buff = alloc_skb_fclone(MAX_TCP_HEADER + 15, sk->sk_allocation); tcp_init_nondata_skb(buff, tp->write_seq++, TCPHDR_SYN); // 添加到发送队列sk_write_queue tcp_connect_queue_skb(sk, buff) // 实际发出syn err = tp->fastopen_req ? tcp_send_syn_data(sk, buff) : tcp_transmit_skb(sk, buff, 1, sk->sk_allocation); // 启动重传定时器 inet_csk_resetxmit_timer(sk, ICSK_TIME_RETRANS, inet_csk(sk)->icsk_rto, TCP_RTO_MAX); }
tcp_connect一口气做了这么几件事:
1,申请一个skb,并将其设置为syn包
2,添加到发送队列上
3,调用tcp_transmit_skb将该包发出(同之前内核发送网络包的方式,传递给网络层)
4.启动一个重传定时器,超时会重发
该定时器的作用是等到一定时间后收不到服务端的反馈的时候来开启重传。首次超时时间是在TCP_TIMEOUT_INIT宏中定义的,该值在Linux3.10版本是1秒, 在一些老版本中是3秒。
TCP在实现过程中,发送队列和重传队列都是sk_write_queue,这两个队列是一并处理的。
5)小结
客户端执行connect函数的时候,把本地socket状态设置成了TCP_SYN_SENT,选了一个可用的端口,接着发出SYN握手请求并启动重传定时器。
在选择端口时,会随机地从ip_local_port_range指
端口后发出syn握手包,如果端口查找失败则抛出异常“Cannot assign requested address”。如果当前可用端口很充足,那么循环很快就可以退出。而如果ip_local_port_range中的端口快被用完了,那么这时候内核就大概率要把循环执行很多轮才能找到可用端口,这会导致connect系统调用的CPU开销上涨。
而如果在connect之前使用了bind,将会使得connect系统调用时地端口选择方式无效,转而使用bind时确定的端口。即如果提前调用bind选了一个端口号,会先尝试使用该端口号,如果传入0也会自动选择一个。但默认情况下一个端口只会被使用一次,所以对于客户端角色的socket,不建议使用bind。
四、完整TCP连接建立过程
在一次TCP连接建立(三次握手)的过程中,并不只是简单的状态的流转,还包括端口选择、半连接队列、syncookie、全连接队列、重传计时器等关键操作。
在三次握手的过程,服务端核心逻辑是创建socket绑定端口,listen监听,最后accept接收客户端的的请求;而客户端的核心逻辑是创建socket,然后调用connect连接服务端。
socket的创建、服务端的listen、客户端的connect在前面都已经讲解过了,那么这里从客户端connect发出syn包之后开始。
1)服务端响应SYN
在服务端,所有的TCP包(包括客户端发来的SYN握手请求)都经过网卡、软中断进入tcp_v4_rcv。在该函数中根据网络包skb的TCP头信息中的目的IP信息查找当前处于listen状态的socket,然后继续进入tcp_v4_do_rcv处理握手过程(因为listen状态的socket不会收到的进入预处理队列。
int tcp_v4_do_rcv(struct sock *sk, struct sk_buff *skb) { ...... if(sk->sk_state == TCP_ESTABLISHED) {} // 服务端收到第一步握手SYN或者第三步ACK都会走到这里 if(sk->sk_state == TCP_LISTEN) { struct sock *nsk = tcp_v4_hnd_req(sk, skb); if(!nsk) goto discard; if(nsk != sk) { if(tcp_child_process(sk, nsk, skb)) { rsk = nsk; goto reset; } return 0; } } if(tcp_rcv_state_process(sk, skb, tcp_hdr(skb), skb->len)) { rsk = sk; goto reset; } } static struct sock *tcp_v4_hnd_req(struct sock *sk, struct sk_buff *skb) { // 查找listen socket的半连接队列 struct request_sock *req = inet_csk_search_req(sk, &prev, th->source, iph->saddr, iph->daddr); if(req) return tcp_check_req(sk, skb, req, prev, false); ...... }
在tcp_v4_do_rcv中判断当前socket是listen状态后,首先会到tcp_v4_hnd_req查看是否处于半连接队列。如果再半连接队列中没有找到对应的半连接对象,则会返回listen的socket(连接尚未创建);如果找到了就将该半连接socket返回。服务端第一次响应SYN的时候,半连接队列自然没有对应的半连接对象,所以返回的是原listen的socket,即nsk == sk。
在tcp_rcv_state_process里根据不同的socket状态进行不同的处理
int tcp_rcv_state_process(struct sock *sk, struct sk_buff *skb, const struct tcphdr th, unsigned int len) { swich(sk->sk_state) { case TCP_LISTEN: // 判断是否为syn握手包 if(th->syn) { ...... if(icsk->icsk_af_ops->conn_request(sk, skb) < 0) return 1; ...... }
其中conn_request是一个函数指针,指向tcp_v4_conn_request。服务端响应SYN的主要逻辑都在整个tcp_v4_conn_request里。
int tcp_v4_conn_request(struct sock *sk, struct sk_buff *skb) { // 查看半连接队列是否满了 if(inet_csk_reqsk_is_full(sk) && !isn) { want_cookie = tcp_syn_flood_action(sk, skb, "TCP"); if(!want_cookie) goto drop; } // 在全连接队列满的情况下,如果有young_ack,那么直接丢弃 if(sk_acceptq_is_full(sk) && inet_csk_reqsk_queue_young(sk) > 1) { NET_INC_STATS_BH(sock_net(sk), LINUX_MIB_LISTENOVERFLOWS); goto drop; } ...... // 分配request_sock内核对象 req = inet_reqsk_alloc(&tcp_request_sock_ops); // 构造syn+ack包 skb_synack = tcp_make_synack(sk, dst, req, fastopen_cookie_present(&valid_foc) ? &valid_foc : NULL); if(likely(!do_fastopen)) { // 发送syn+ack响应 err = ip_build_and_send_pkt(skb_aynack, sk, ireq->loc_addr, ireq->rmt_addr, ireq->opt); // 添加到半连接队列,并开启计时器 inet_csk_reqsk_queue_hash_add(sk, req, TCP_TIMEOUT_INIT); } else ... }
在这里首先判断半连接队列是否满了,如果满了进入tcp_syn_flood_action去判断是否开启了tcp_syncookies内核参数。如果队列满且未开启tcp_syncookies,那么该握手包将被直接丢弃。
TCP Syn Cookie 是一个防止 SYN Flood 攻击的技术。当服务器接收到大量伪造的 SYN 请求时,可以消耗掉所有的连接资源,导致合法用户无法建立新的连接,这种攻击方式被称为 SYN Flood 攻击。SYN Flood 是一种 DoS(Denial of Service,服务拒绝)攻击。
这种技术的主要思想是不在服务器上为每个收到的 SYN 请求分配资源,而是通过计算一个 Cookie(实质上是一个哈希值),将这个 Cookie 作为 SYN-ACK 包的序列号发回客户端。当客户端回复 ACK 包时,服务器可以从 ACK 包的确认号中恢复出之前发送的 Cookie,从而验证这个连接请求是有效的。
这种方式可以有效抵御 SYN Flood 攻击,因为服务器不需要为每个 SYN 请求分配资源,伪造的 SYN 请求不会消耗服务器的资源。但是,SYN Cookie 技术也有一些局限性,例如它不兼容一些 TCP 的高级特性(如窗口缩放),并且在计算 Cookie 时也会消耗一些 CPU 资源
接着判断全连接队列是否满了,因为全连接队列满也会导致握手异常,那干脆就在第一次握手的时候也判断了。如果全队列满了,且young_ack数量大于1的话,那么同样也是直接丢弃。
young_ack是半连接队列里保存着的一个计时器,记录的是刚有SYN到达,没有被SYN_ACK重传定时器重传过SYN_ACK,同时也没有完成过三次握手的sock数量。
inet_csk_reqsk_queue_young(sk) > 1这一判断,其实是在检查是否存在"年轻"的连接请求。如果存在这样的请求,而且全连接队列又已经满了,那么就会选择拒绝新的连接请求,以防止服务器过载。
接下来是构造synack包,然后通过ip_build_and_send_pkt把它发送出去。
最后把当前的握手信息添加到半连接队列,并且启动计时器。计时器的作用是如果某个时间内还收不到客户端的第三次握手,服务端就会重传synack包。
此时半连接队列中的request_sock的状态为SYN_RECV。等到服务器收到客户端的ACK报文,也就是三次握手完成后,request_sock 会被"升级"为一个完整的 sock 结构体,状态变为 ESTABLISHED。
2)客户端响应SYNACK
客户端收到服务端发来的synack包的时候,由于自身状态是TCP_SYN_SENT,所以不会进入ESTABLISHED、LISTEN分支,同样进入tcp_rcv_state_process函数。
int tcp_rcv_state_process(struct sock *sk, struct sk_buff *skb, const struct tcphdr *th, unsigned int len) { switch(sk->sk_state) { // 服务端收到第一个SYN包 case TCP_LISTEN: ...... // 客户端第二次握手处理 case TCP_SYN_SENT: // 处理synack包 queued = tcp_rcv_synsent_state_process(sk, skb, th, len); ...... return 0; }
tcp_rcv_synsent_state_process是客户端响应synack的主要逻辑
static int tcp_rcv_synsent_state_process(struct sock *sk, struct sk_buff *skb, const struct tcphdr *tp, unsigned int len) { ...... tcp_ack(sk, skb, FLAG_SLOWPATH); // 连接建立完成 tcp_finish_connect(sk, skb); if(sk->sk_write_pending || icsk->icsk_accept_queue.rskq_defer_accept || icsk->icsk_ack.pingpong) // 延迟确认...... else { tcp_send_ack(sk); } } f *skb,
1.tcp_ack(sk, skb, FLAG_SLOWPATH):这行代码在收到SYN-ACK包后更新了socket的状态,包括序列号、确认号等。
tcp_clean_rtx_queue:删除重传队列中已被确认的数据包,停止重传定时器
在TCP协议中,当发送一个数据包时,发送方将这个数据包存储在重传队列中,并启动一个定时器。如果在定时器超时之前收到了这个数据包的确认(ACK),那么发送方就知道这个数据包已经成功地到达接收方,它就会从重传队列中删除这个数据包。否则,当定时器超时时,发送方就会重新发送这个数据包。
tcp_clean_rtx_queue函数就是处理这个重传队列的函数。它遍历重传队列,查看哪些数据包已经得到了确认,然后从重传队列中删除这些数据包。它还会计算网络的往返时间(RTT),以便于调整TCP的超时时间。
如果重传队列中的所有数据包都已经被确认,那么停止重传定时器。
2.tcp_finish_connect(sk, skb):这行代码完成了TCP连接的建立。它将socket的状态从SYN_SENT改为ESTABLISHED,初始化TCP连接的拥塞控制算法、接收缓存和发送缓存空间等信息,开启keep alive计时器,然后唤醒等待连接完成的进程。
Keep-alive计时器就是用于控制发送keep-alive数据包的计时器。通常,当一个TCP连接上没有任何数据包的传输时,我们就启动这个计时器。如果在计时器超时之前有新的数据包在这个连接上发送或接收,那么我们就重置计时器。如果计时器超时,那么我们就发送一个keep-alive数据包,并重新启动计时器等待响应。如果接收到了对这个数据包的响应,那么我们就知道连接仍然存在。如果在一定时间内没有收到响应,那么我们就假定连接已经断开,并将其关闭。
3.满足TCP的延迟确认(Delayed ACK)机制:这种情况下,ACK包可能会和后续的数据包一起发送,以减少网络上的包的数量。
4.不满足延迟确认机制:立即调用tcp_send_ack(sk),申请和构造ACK包然后发送出去。这个ACK包是对对方SYN-ACK包的确认,也是TCP三次握手的最后一步。
即客户端响应来自服务端的synack时清除了connect时设置得重传定时器,把当前socket状态设置为ESTABLISHED,开启保活计时器然后发出第三次握手的ack确认。
3)服务端响应ACK
服务端响应第三次握手的ack时同样会进入tcp_v4_do_rcv。
int tcp_v4_do_rcv(struct sock *sk, struct sk_buff *skb) { ...... if(sk->sk_state == TCP_ESTABLISHED) {} // 服务端收到第一步握手SYN或者第三步ACK都会走到这里 if(sk->sk_state == TCP_LISTEN) { struct sock *nsk = tcp_v4_hnd_req(sk, skb); if(!nsk) goto discard; if(nsk != sk) { if(tcp_child_process(sk, nsk, skb)) { rsk = nsk; goto reset; } return 0; } } }
由于此处已经是第三次握手了,半连接队列里会存在第一次握手时留下的半连接信息,所以tcp_v4_hnd_req会在半连接队列里找到半连接request_sock对象后进入tcp_check_req
static struct sock *tcp_v4_hnd_req(struct sock *sk, struct sk_buff *skb) { // 查找listen socket的半连接队列 struct request_sock *req = inet_csk_search_req(sk, &prev, th->source, iph->saddr, iph->daddr); if(req) return tcp_check_req(sk, skb, req, prev, false); ...... } struct sock *tcp_check_req(...) { // 创建子sock child = inet_csk(sk)->icsk_af_ops->syn_recv_sock(sk, skb, req, NULL); ...... // 清理半连接队列 inet_csk_reqsk_queue_unlink(sk, req, prev); inet_csk_reqsk_queue_removed(sk, req); // 添加全连接队列 inet_csk_reqsk_queue_add(sk, req, child); return child; }
该函数完成了以下工作:
1.判断接收队列是不是满了,没满则创建子sock(tcp_sock)
2.把request_sock从半连接队列删除
3.将request_sock添加到全连接队列链表的尾部,并与新创建的sock关联
因为是第三次握手所以返回了新的子sock,那么显然nsk!=sk,所以会执行tcp_child_process来为新的子sock进行一些初始化和处理工作,如设置TCP标志等,如果处理成功则会返回0。
int tcp_child_process(struct sock *parent, struct sock *child, struct sk_buff *skb) { int ret = 0; int state = child->sk_state; if (!sock_owned_by_user(child)) { ret = tcp_rcv_state_process(child, skb, tcp_hdr(skb), skb->len); // 进行状态处理 if (state == TCP_SYN_RECV && child->sk_state != state) // 状态处理结束后socket的状态发生了变化 // 调用sock_def_readable函数发送可读事件通告给listening socket,告知其可以进行accept系统调用 parent->sk_data_ready(parent, 0); } else { // 新的socket被进行系统调用的进程锁定;因为这是新的socket,所以在tcp_v4_rcv加的锁不会起到保护新socket的作用 __sk_add_backlog(child, skb); // 加入到后背队列 } bh_unlock_sock(child); sock_put(child); return ret; }
可以看到其中再一次调用了tcp_rcv_state_process,然后唤醒等待队列上的进程。
int tcp_rcv_state_process(struct sock *sk, struct sk_buff *skb, const struct tcphdr *th, unsigned int len) { switch(sk->sk_state) { // 服务端收到第一次握手的SYN包 case TCP_LISTEN: ...... // 客户端第二次握手处理 case TCP_SYN_SENT: ...... // 服务端收到第三次握手的ACK包 case TCP_SYN_RECV: // 改变状态为连接 tcp_set_state(sk, TCP_ESTABLISHED); ...... } }
服务端响应第三次握手ACK所做的工作就是把当前半连接对象删除,创建了新的sock后加入全连接队列,最后将新连接状态设置为ESTABLISHED。
4)服务端accept
当服务端调用accept时主要的逻辑就是创建socket对象,然后从全连接队列中取出request_sock,将其中保存的第三次握手时创建的sock取出并与socket关联,随后释放request_sock。
struct sock *inet_csk_accept(struct sock *sk, int flags, int *err) { // 从全连接都列中获取 struct request_sock_queue *queue = &icsk->icsk_accept_queue; req = reqsk_queue_remove(queue); newsk = req->sk; return newsk; } struct request_sock { // 和其它struct request_sock对象形成链表 struct request_sock *dl_next; /* Must be first member! */ // SYN段中客户端通告的MSS u16 mss; // SYN+ACK段已经重传的次数,初始化为0 u8 retrans; ...... // SYN+ACK段的超时时间 unsigned long expires; // 指向tcp_request_sock_ops,该函数集用于处理第三次握手的ACK段以及后续accept过程中struct tcp_sock对象的创建 const struct request_sock_ops *rsk_ops; // 连接建立前无效,建立后指向创建的tcp_sock结构 struct sock *sk; ...... };
5)小结
TCP连接建立的操作可以简单划分为两类:
1.内核消耗CPU进行接收、发送或者处理,包括系统调用、软中断和上下文切换。它们的耗时基本是几微妙左右。
2.网络传输将包从一台机器上发出,经过各式各样的网络互联设备道到达目的及其。网络传输的耗时一般在几毫秒到几百毫秒,远超于本机CPU处理。
由于网络传输耗时比双端CPU耗时要高1000倍不止,所以在正常的TCP连接建立过程中,一般堪虑网络延时即可。
一个RTT指的是包从一台服务器到另一台服务器的一个来回的延迟时间。从全局来看,TCP连接建立的网络耗时大约需要三次传输,再加上少许的双方CPU开销,总共大约比1.5倍RTT大一点点。
不过从客户端的角度来看,只要ACK包发出了,内核就认为连接建立成功,可以开始发送数据了。所以如果在客户端统计TCP连接建立耗时,只需要两次传输耗时——即比1个RTT多一点时间(从服务端视角来看也是同理)。