ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

【Linux网络】TCP 协议(二):三次握手、四次挥手、滑动窗口与拥塞控制一网打尽

【Linux网络】TCP 协议(二):三次握手、四次挥手、滑动窗口与拥塞控制一网打尽

目录

  • 1 ~> 连接管理机制
    • 1.1 三次握手
      • 1.1.1 管理连接
      • 1.1.2 状态变化
      • 1.1.3 为什么要三次握手???
      • 1.1.4 三次握手是不是真的三次握手?
    • 1.2 四次挥手
      • 1.2.1 状态变化
      • 1.2.2 为什么是四次挥手?三次行不行?
      • 1.2.3 主动方都close了还怎么读 & 发?
      • 1.2.4 被动方不断开连接会怎么样?
      • 1.2.5 主动方为什么要进入TIME_WAIT状态?
  • 2 ~> 滑动窗口
    • 2.1 滑动窗口的移动
    • 2.2 丢包处理
  • 3 ~> 流量控制
  • 4 ~> 拥塞控制
  • 5 ~> TCP 收尾
    • 5.1 延迟应答
    • 5.2 粘包问题
    • 5.3 TCP 异常情况
    • 5.4 UDP 和TCP 区别
    • 5.5 底层TCP 协议栈数据结构
  • 6 总结

在上一篇博客,我们已经对TCP 协议格式与相关字段作了详细介绍,今天我们继续深入底层,探讨TCP 是如何在复杂的网络环境中兼顾可靠性传输效率的。

1 ~> 连接管理机制

1.1 三次握手

1.1.1 管理连接

多个客户端连接服务端,对于多个连接就需要服务端管理。
先描述,再组织!
当客户端connect(),客户端与服务端双方建立连接。在应用层看,连接只是一个整数 fd(文件描述符);但在内核深处,一个连接由两个核心数据结构共同“描述”:

  • 套接字对象(struct socket):位于 VFS/接口层,负责对上暴露出fd接口,让应用程序能够调用read()write()

  • TCP 控制块 TCB(struct tcp_sock / struct sock):位于传输层,负责存储该连接的所有 TCP 动态状态(包括滑动窗口大小 rwnd、拥塞窗口 cwnd、序列号/确认号、重传定时器以及接收/发送缓冲区队列)。

1.1.2 状态变化

  • 客户端:
    • CLOSED -> SYN_SENT:客户端调用connect,发送同步报文段;
    • SYN_SENT -> ESTABLISHED:connect成功返回(connect阻塞等待,收到服务端SYN+ACK后,向服务端应答)。
  • 服务端:
    • CLOSED -> LISTEN:服务器端调用listen后进入LISTEN状态,等待客户端连接;
    • LISTEN -> SYN_RCVD:一旦监听到连接请求(同步报文段),就将该连接放入内核等待队列中,并向客户端发送SYN确认报文;
    • SYN_RCVD -> ESTATABLISHED:服务端一旦收到客户端的确认报文,就进入ESTATABLISHED状态,可以进行读写数据了。

如上所有动态变化(三次握手),均由双方操作系统在客户端connect发起连接后自动完成。

1.1.3 为什么要三次握手???

(1)以最短方式验证TCP 的全双工。
本质:探测双方网络是否畅通!

  • 第一次握手:客户端发送报文(SYN)-> 服务端

服务端接收后,代表客户端可以发

  • 第二次握手:服务端发送报文(SYN + ACK)-> 客户端

客户端收到后,代表服务端可以发也可以收

  • 第三次握手:客户端发送应答 -> 服务端

服务端收到应答,代表客户端可以收

结论:双方通过最少次数(3次)验证了TCP 的全双工,即双方网络通畅。

(2)以最小成本,100%确认双方的通信意愿。
既然要双方通信,就需要征求双方意愿。只有都同意了,才能够通信。

  • 第一次握手:客户端说我愿意。
  • 第二次握手:服务端说我收到你的请求了,我也愿意。
  • 第三次握手:客户端确认。
  • 服务端对客户端请求无脑接收!
  • 前两次不携带数据,还未验证全双工。
  • 第三次握手,客户端可以携带数据,因为在客户端眼里:服务端可以收也可以发。

1.1.4 三次握手是不是真的三次握手?


第二次握手实际上是捎带应答。
第二次握手:

  • ACK:表示我同意建立连接。
  • SYN:我也想和你建立连接。

如果不合并,三次握手就会变成四次握手。
但利用捎带应答机制,合并发送就可以减少一次网络包的发送,是一种精妙且高效的设计。

1.2 四次挥手


建立连接后,双方就可以正常通信了。通常当客户端把全部数据发送完毕后,就需要断开连接,但也有可能服务端主动断开。以客户端主动断开为例:

1.2.1 状态变化

  • 第一次挥手:主动方客户端close(fd)发起关闭

ESTABLISHED -> FIN_WAIT_1(主动方): 主动方应用程序调用关闭连接的 API,向对端发送FIN报文后进入此状态。

代表含义: “我已经没有数据要发给你了,我要关闭通道,正在等待你的确认。”

  • 第二次挥手:被动方确认请求

CLOSE_WAIT(被动方): 被动方的操作系统内核收到 FIN 后,自动立刻回复 ACK 确认报文,进入此状态。

代表含义: “我收到你想关闭的请求了。但我可能还有数据没发完,或者应用层的善后逻辑还没处理完,请等我处理完毕。”

FIN_WAIT_2(主动方): 主动方收到了被动方发来的 ACK 确认报文。

代表含义: “我知道你收到我的关闭请求了。现在我进入半关闭状态,安静地等你把剩下的事情处理完,等你发 FIN 给我。”

  • 第三次挥手:被动方处理完毕,发起关闭

LAST_ACK(被动方): 被动方的应用程序处理完了所有业务,也发送了最后的数据,于是主动调用close()发送 FIN 报文,进入此状态。

代表含义: “我的事情也全办完了,数据发完了,现在我也要关闭连接了,正在等待你的最后一次确认。”

  • 第四次挥手:主动方最终确认,连接终止

TIME_WAIT(主动方): 主动方收到对端的 FIN,并回复最后一次 ACK 后,进入此状态。

代表含义: “我确认了你的关闭请求。”

(注:此时主动方并没有立刻死亡,而是要在这个状态硬熬 2MSL(两倍的最大报文段生存时间,通常约 1~4 分钟)。目的是为了防止最后的 ACK 丢失导致对端无法正常关闭,并且让网络中残留的迟到报文自然消亡,防止串扰下一个新连接。)

CLOSED(被动方先入,主动方后入):

被动方只要收到了最后的 ACK,立刻进入 CLOSED,彻底释放资源。

主动方在熬完 TIME_WAIT 的倒计时后,也进入 CLOSED,彻底释放资源。

代表含义: “连接已完全释放,尘归尘,土归土。”

1.2.2 为什么是四次挥手?三次行不行?

四次挥手:以最小次数,验证双方断开连接的共识
四次握手之所以可以合并为三次握手,是由于服务端ACK 与SYN可以用捎带应答的方式同时发送。
但四次挥手时,主动方数据发送完毕,主动断开连接,但被动方可能还有数据需要发送,需要等到处理完毕后才能断开连接。
所以,四次挥手时ACK 与FIN不能合并。

1.2.3 主动方都close了还怎么读 & 发?

(1)谁在真正执行“读”和“发”?(用户态 vs 内核态)

当主动方发起关闭时,系统分为两个层面:

  • 应用层(用户态): 应用程序调用 close(fd) 后,操作系统会回收该进程对该 fd 的句柄。此时如果代码试图再次调用 read(fd) 或 write(fd),系统会直接报错。应用程序层面已经彻底无法进行任何读写了。

  • 网络栈(内核态): 内核依然保留着该连接的控制块(如 Linux 内核中的 struct sock)。当被动方发回 ACK 或 FIN 报文时,是内核的 TCP 协议栈在自动接收,并由内核自动回复最后的 ACK。

结论:你的代码并没有在被动方发 FIN 时去调用 read(),也没有在回复 ACK 时去调用 write() —— 这些纯属操作系统的内核行为,不需要应用层代码干预。

(2)close()shutdown()的关键区别(全关闭 vs 半关闭)

如果你观察到主动方在发起关闭后还能继续读取被动方发来的业务数据,那主动方调用的其实不是close(),而是shutdown()

// 局部关闭intshutdown(intsockfd,inthow);
  • close(fd)(全关闭): 读写通道同时关闭。应用程序既不能读,也不能写。

  • shutdown(fd, SHUT_WR)(半关闭): 仅关闭写通道。

内核会向对端发送FIN报文,告知:“我不会再给你发数据了”。

但读通道依然保持开启。主动方的应用程序依然可以继续调用read(),把被动方还没发完的数据全部读完,直到被动方也发送 FIN(此时 read() 返回 0)。

(3)如果已经close(),被动方还发数据会怎样?

如果主动方调用的是完全关闭close(fd),而被动方此时没有发送FIN,而是尝试继续发送新的业务数据:

主动方的内核发现该 Socket 已经被用户态程序彻底丢弃(已经没有进程会来读取数据了),就会直接丢弃该数据,并向被动方回复一个 RST(重置)报文,强制终止连接

结论:在标准的close()流程中,应用程序已经完全退场,后续的ACKFIN交互全由内核一手包办;而如果应用程序还能“读数据”,那它用的一定是半关闭shutdown()

1.2.4 被动方不断开连接会怎么样?

  • 被动方:资源泄漏与服务宕机

卡住状态:CLOSE_WAIT(等待应用层调用 close())。

核心危害: Socket 句柄(fd)和内核缓冲区持续占用无法释放。高并发下会快速耗尽系统的文件描述符,抛出 Too many open files 错误,导致服务崩溃。

  • 主动方:内核超时自动兜底

卡住状态:FIN_WAIT_2(等待被动方的 FIN)。

解决机制: 主动方不会被无限期卡死。Linux 内核会在超时后直接强行销毁该 Socket 并回收资源。

1.2.5 主动方为什么要进入TIME_WAIT状态?

被动方在close(fd)时,即向主动方发送FIN请求,主动方接收到后进入TIME_WAIT状态,并向被动方发送确认应答。
为什么主动断开连接的一方为什么要进入TIME_WAIT 状态,而不是直接进入CLOSED呢!
(1)保证最后一个 ACK 能够可靠到达(可靠终止连接)

在四次挥手中,第 4 步是主动方发给被动方的最终确认报文(ACK)。由于 IP 网络不可靠,这个 ACK 可能会在半路丢包。

如果丢包了: 被动方因为没收到 ACK,会触发超时重传,重新发送第 3 步的 FIN 报文。

如果主动方没有 TIME_WAIT 而是直接关闭: 当收到重传的 FIN 时,主动方的内核已经完全删除了该 Socket 状态,只能回应一个 RST(重置)报文。这会让被动方以为发生了网络异常或崩溃,而不是优雅关闭。

如果主动方处于 TIME_WAIT 状态: 主动方依然保留着连接的信息。一旦收到重传的 FIN,就可以重新补发一次 ACK,协助被动方平滑关闭

(2)防止“旧连接的延迟报文”污染新连接

TCP 数据包在路由网络传输时,可能会因为网络拥堵而出现严重延迟。

假设主动方关闭连接后,系统立即释放端口,并且马上用相同的四元组(源 IP、源端口、目的 IP、目的端口)建立了一个全新的 TCP 连接。

如果此时,上一条旧连接里延迟在半路上的老数据包突然送达了:

新连接的 TCP 协议栈无法区分这是“上一代遗孤”还是“这一代的新数据”。

协议栈可能会直接接收这些旧数据,导致应用层数据错乱甚至损坏。

(3)那么TIME_WAIT 是多长时间?
2个MSL

2MSL 时间的意义:1 个 MSL 是报文在网络中的最大存活时间。等待 2MSL(来回最长时间)可以确保上一次连接在两个方向上的所有残余报文都已在网络中彻底自然消失。之后重新建立连接时,网络就是绝对干净的。

当服务端主动断开连接,此时,就会处于TIME_WAIT状态。在此期间,内核默认不允许任何新的Socket绑定这个还在“扫尾”的端口号,就会导致bind error。
解决办法:设置SO_REUSERADDR
在调用 bind() 之前,加上下面的代码,告诉内核允许分用处于TIME_WAIT状态的端口号。
内核会根据TCP 序号字段区分老旧报文。

intopt=1;setsockopt(listen_fd,SOL_SOCKET,SO_REUSEADDR,&opt,sizeof(opt));

一句话总结:TIME_WAIT 是主动方为了给被动方“垫后”(处理 ACK 丢失后的重传),以及给网络“扫尾”(清理残留数据包)而付出的必要时间代价。

2 ~> 滑动窗口

发送方每发送一个报文,接收方就要确认应答ACK,发送方收到ACK 后继续发送报文,如果这样,那么效率就会很低。

如果发送方一次发送多个报文,就会吧多个等待的时间重叠在一起,那么效率就比较高了。

窗口大小指的是无需等待确认应答而可以继续发送数据的最大值。上图的窗口大小就是4000个字节(四个段)。
我们将发送缓冲区抽象为一个一维数组,滑动窗口就是其中一段数据区,通过两个数组下标就可以定位,起始位置 + 偏移量。
窗口的大小由startend指针维护。

  • 发送窗口中四个段的时候,不需要等待任何ACK,直接发送;

  • 收到第一个ACK后,滑动窗口向后移动,继续发送第五个段的数据,依次类推;

  • 操作系统内核为了维护这个滑动窗口,需要开辟 发送缓冲区 来记录当前还有哪些数据没有应答;只有确认应答过的数据,才能从缓冲区删掉;

  • 窗口越大,则网络的吞吐率就越高。

  • start之前的数据已经确认送达了,即start 就是确认序号(下次发送数据从start 位置开始发)。

  • 发送数据的量由对方接收缓冲区决定,则end = start + win(接收缓冲区剩余空间大小)。

2.1 滑动窗口的移动

滑动窗口中的数据一次性发送,当收到第一个段的ACK后,start 指针移动到确认序号处。

(1)窗口能不能向左移?
不能,确认序号是递增的,滑动窗口只能向右移动。
(2)能不能变大,变小,不变,为零?
可以,由接收方接收缓冲区剩余空间大小决定。

2.2 丢包处理

根据数据段在滑动窗口的位置,丢包有三种情况:

  • 最左侧报文丢了
  • 中间报文丢了
  • 最右侧报文丢了

但由于滑动窗口会向右移动,所以最终都会转化为最左侧丢包的情况。而丢包又分为报文丢了和发送方没有收到应答,已最左侧丢包为例:
(1)应答丢了
一次发送多个报文,第一个或者某个报文的应答丢了,但由于接收方收到了该报文,所以只要发送方收到该报文之后的某个报文的应答。该应答的确认序号就代表之前的的数据全部收到了。
例如1001的这个应答丢了,但发送方收到2001的应答,就说明2001之前的数据收到了,即使1001这个报文丢了也没事。

(2)报文丢了

发送了多个报文,但第二个报文(1001~2000)丢了,那么接收方收到后续报文后就会一直回复1001,即 start 指针不会向右移动。

当发送方收到应答发现应答中确认序号为1001,就会意识到1001~2000 这个报文可能丢了。但此时并不会立即重新发送该报文,具体有两种重传机制:

  • 快重传机制

当发送方收到三个相同的应答(1001)后,就会重发。

由于后续应答的确认序号都是1001,滑动窗口起始位置指向1001,即1001~2000的数据一直在发送缓冲区,保证丢包后数据能找到。

当接收方收到1001~2000的报文后,直接回复7001,滑动窗口start 一次性移动到7001(下次数据从7001处开始发)。

  • 超时重传机制

丢包后的兜底措施,发送方在一定时间内没有收到应答,自动重传1001~2000这个报文。

3 ~> 流量控制

接收端处理数据的速度是有限的。如果发送端发的太快,导致接收端的缓冲区被打满,这个时候如果发送端继续发送,就会造成丢包,继而引起丢包重传等等一系列连锁反应。

因此TCP支持根据接收端的处理能力,来决定发送端的发送速度。这个机制就叫做流量控制(FlowControl)。

滑动窗可以用来实现流量控制。

  • 接收端将自己可以接收的缓冲区剩余空间大小放入 TCP 首部中的 “窗口大小” 字段,通过ACK端通知发送端;
  • 窗口大小字段越大,说明网络的吞吐量越高;
  • 接收端一旦发现自己的缓冲区快满了,就会将窗口大小设置成一个更小的值通知给发送端;
  • 发送端接受到这个窗口之后,就会减慢自己的发送速度;
  • 如果接收端缓冲区满了,就会将窗口置为0;这时发送方不再发送数据,但是需要定期发送一个窗口探测数据段,使接收端把窗口大小告诉发送端。

4 ~> 拥塞控制

通过滑动窗口就可以一次发送多个报文,大大提高了通信效率。但通信还会收到网络状态的影响,如果当前的网络状态非常拥堵,再向网络中发送大量的报文,会使得网络更加拥堵,导致大面积丢包。
所以,TCP 引入了拥塞控制。
拥塞控制:网络拥塞时大面积丢包,不能立即重发,而是采用慢启动,先发少量的数据,探探路,摸清当前的网络拥堵状态,再决定按照多大的速度传输数据。

  • 此处引入一个概念称为拥塞窗口
  • 发送开始的时候, 定义拥塞窗口大小为1;
  • 每次收到一个ACK应答,拥塞窗口加1,1 -> 2 -> 4 -> 8,即拥塞窗口大小以2的指数增长;
  • 每次发送数据包的时候,将拥塞窗口和接收端主机反馈的窗口大小做比较,取较小的值作为实际发送的窗口,即滑动窗口大小 = min(win, 拥塞窗口)
  • 像上面这样的拥塞窗口增长速度,是指数级别的。 "慢启动" 只是指初使时慢, 但是增长速度非常快

  • 为了不增长的那么快,因此不能使拥塞窗口单纯的加倍,引入一个叫做慢启动的阈值;
  • 当拥塞窗口超过这个阈值的时候,不再按照指数方式增长,而是按照线性方式增长,继续试探网络拥塞状况。

  • 当TCP开始启动的时候,慢启动阈值等于窗口最大值;
  • 在再次遇到网络拥塞的时候,慢启动阈值会变成原来的一半,同时拥塞窗口置回1(慢启动);

少量的丢包,我们仅仅是触发超时重传;大量的丢包,我们就认为网络拥塞;当TCP通信开始后,网络吞吐量会逐渐上升;随着网络发生拥堵,吞吐量会立刻下降。
拥塞控制, 归根结底是TCP协议想尽可能快的把数据传输给对方,但是又要避免给网络造成太大压力的折中方案。

5 ~> TCP 收尾

5.1 延迟应答

如果接收数据的主机立刻返回ACK应答, 这时候返回的窗口可能比较小.

  • 假设接收端缓冲区为1M, 一次收到了500K的数据,如果立刻应答,返回的窗口就是500K;
  • 但实际上可能处理端处理的速度很快,10ms之内就把500K数据从缓冲区消费掉了;
  • 在这种情况下,接收端处理还远没有达到自己的极限,即使窗口再放大一些,也能处理过来;
  • 如果接收端稍微等一会再应答,比如等待200ms再应答,那么这个时候返回的窗口大小就是1M;

一定要记得,窗口越大,网络吞吐量就越大,传输效率就越高,我们的目标是在保证网络不拥塞的情况下尽量提高传输效率。

那么所有的包都可以延迟应答么?
肯定也不是

  • 数量限制:每隔N个包就应答一次;
  • 时间限制:超过最大延迟时间就应答一次;

具体的数量和超时时间,依操作系统不同也有差异,一般N取2,超时时间取200ms。

5.2 粘包问题

  • 首先要明确,粘包问题中的 “包”,是指的应用层的数据包。

  • 站在传输层的角度,TCP是一个一个报文过来的。按照序号排好序放在缓冲区中。

  • 站在应用层的角度,看到的只是一串连续的字节数据。

  • 那么应用程序看到了这么一连串的字节数据,就不知道从哪个部分开始到哪个部分,是一个完整的应用层数据包。

那么如何避免粘包问题呢? 归根结底就是一句话,明确两个包之间的边界。

  • 对于定长的包,保证每次都按固定大小读取即可;例如上面的Request结构,是固定大小的,那么就从缓冲区从头开始按sizeof(Request)依次读取即可;
  • 对于变长的包,可以在包头的位置,约定一个包总长度的字段,从而就知道了包的结束位置;
  • 对于变长的包,还可以在包和包之间使用明确的分隔符(应用层协议,是程序猿自己来定的,只要保证分隔符不和正文冲突即可)。

5.3 TCP 异常情况

  • 进程终止:OS自动清理文件描述符,TCP 协议栈会自动向对端发送FIN包,正常四次挥手。
  • 机器重启:重启之前OS会自动关闭所有进程,正常四次挥手。
  • 机器掉电/网线断开:接收端认为连接还在, 一旦接收端有写入操作,接收端发现连接已经不在了,就会进行reset。即使没有写入操作,TCP自己也内置了一个保活定时器,会定期询问对方是否还在。如果对方不在,也会把连接释放。
  • 另外,应用层的某些协议,也有一些这样的检测机制。例如HTTP长连接中,也会定期检测对方的状态。例如在QQ断线之后,也会定期尝试重新连接。

5.4 UDP 和TCP 区别

UDP:

  • 无连接
  • 面向数据报
  • 不可靠

TCP:

  • 有连接
  • 面向字节流
  • 可靠

但并不能说UDP 不可靠就是其缺点,这是UDP 设计的自身特点。
UDP 和TCP 都有各自适用的场景,如果某个场景更适用UDP 但又需要一定的可靠性,那么我们就可以将TCP 的各种保证可靠性的机制引入到应用层:

  • 引入序列号,保证数据顺序;
  • 引入确认应答,保证对端收到报文;
  • 引入超时重传,保证不丢包

5.5 底层TCP 协议栈数据结构

一张图看懂从用户空间的进程如何通过文件描述符,最终关联到内核中具体的 TCP 协议栈数据结构。

structsocket<--BSD Socket 层(对用户态暴露的通用接口) └─>structsock<--通用网络层套接字(锁、接收/发送队列、引用计数) └─>structinet_sock<--INET 协议族套接字(IP 协议相关:源/目的 IP、端口、TTL) └─>structinet_connection_sock<--面向连接的套接字(握手、重传定时器、ACK 状态机) └─>structtcp_sock<--TCP 协议专属(滑动窗口、SACK、SEQ 序号、拥塞控制)

极致的代码复用(按能力划分抽象层)

struct sock(通用套接字):抽出所有协议的共性。无论是 TCP、UDP,还是 UNIX 本地套接字,都需要收发队列、锁机制、引用计数,这些全在 sock 里。

struct inet_sock(IP 协议族套接字):抽出所有基于 IP 协议的共性。UDP 和 TCP 都需要源 IP、目的 IP、源端口、目的端口、TTL 等,这些放在 inet_sock 中,UDP 直接继承它即可。

struct inet_connection_sock(面向连接的套接字):抽出所有“面向连接”协议的共性。TCP、SCTP、DCCP 都需要三次握手、连接队列(全连接/半连接)、重传定时器、ACK 状态机,这些统一交由 icsk 处理。

struct tcp_sock(TCP 专属套接字):只留 TCP 特有的属性。如滑动窗口大小(snd_wnd)、选择性确认(SACK)、拥塞控制算法参数(cwnd)、滑动序号等。

极度节省内存(按需分配)

在并发量极高的服务器上,系统内核中可能维持着上百万个 Socket:

如果不分层,只设计一个包含所有功能的巨大全局结构体,那么每一个简单的 UDP Socket 也要被迫背负 TCP 庞大的滑动窗口和拥塞控制字段。

通过分层继承,UDP 创建 Socket 时只需分配struct udp_sock的内存;只有 TCP 才会分配全量的struct tcp_sock内存。在数百万连接的规模下,这种设计节省了极为客观的物理内存。

零开销的“类型转换”与高性能多态

在 C++ 等面向对象语言中,多态通常依赖虚函数表(vtable)和额外的指针开销。

而在 Linux 内核中,子类结构体的第一个字段永远是父类结构体。这种内存布局保证了父类与子类的结构体首地址完全重合(偏移量 Offset 为 0)。

内核在处理数据包时,可以极其优雅且零性能损耗地把struct tcp_sock*强转为struct sock*传给通用逻辑,需要使用 TCP 特性时,再通过container_of宏或类型强转轻松变回struct tcp_sock*

6 总结

到这里,TCP 的学习就结束了!
因为TCP 要保证可靠性和高性能,所以比UDP 要复杂得多。
可靠性:

  • 校验和
  • 序列号
  • 连接管理
  • 确认应答
  • 超时重传
  • 流量控制
  • 拥塞控制

提高性能:

  • 滑动窗口
  • 快重传
  • 延迟应答
  • 捎带应答
返回列表