TCP,UDP
TCP
特点:
- 面向连接
- 超时重传
- 拥塞控制
TCP三次握手
TCP四次挥手
UDP
特点:
- 无连接
- 不可靠
使用udp的服务:DNS,SNMP这些场景对延时、丢包不敏感。
Socket
socket是网络编程中绕不开的一个话题,最早由伯克利大学的研究员提出,也称伯克利套接字。它可以视为一个用于屏蔽底层协议栈的接口。
下图是socket网络编程经典的Client/Server模型。
TCP模型
UDP模型
我们可以将整个socket网络的数据传输想象成打电话。打电话就需要有电话机,需要安装电话线,有了电话机装好电话线,我们还需要购买一个电话号码,这样别人才知道怎么能找到你。对比socket的api,bind就像是去电信公司开户,listen就是听到了电话铃声,accept就是拿起电话开始接听。
| socket api | socket编程含义 | 现实世界 |
| socket | 创建socket | 安装电话机 |
| bind | 绑定端口 | 开户,设定电话号码 |
| listen | 监听描述符 | 听到电话响铃 |
| connect | 建立连接 | 打通电话 |
| accept | 接受数据 | 收听 |
| send/sendto | 发送数据 | 说话 |
| recv/recvfrom | 接收数据 | 收听 |
| close/shutdown | 关闭描述符 | 关闭文件 |
- Socket API概述
socket – 创建套接字
int socket(int domain, int type, int protocol);
- domain 指代
PF_INET、PF_INET6以及PF_LOCAL等,表示要创建何种套接字 - type 的可用值
SOCK_STREAM: 字节流,对应 TCPSOCK_DGRAM:数据报,对应 UDPSOCK_RAW: 原始套接字
- Protocol 通信协议,depreciated,一般填写0
bind – 绑定电话号码
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
- sockfd:socket创建出的文件描述符
- addr:通用地址,注意这里的类型本身应该是void*,只是socket创建时c还不支持void*类型。bind 函数会根据 len 字段判断传入的参数 addr 该怎么解析,len 字段表示的就是传入的地址长度,它是一个可变值。
- addrlen:地址长度
对于使用者来说,每次传参的时候我们都需要把ipv4,ipv6或者本地套接字转化为通用套接字的格式,如下:
struct sockaddr_in addr;
bind(sock, (struct sockaddr*)&addr, sizeof(addr));
故而我们可以把bind的参数理解成 void*, 只是BSD设计socket的时候(1982年), 那时C还没有void*的支持。
listen – 接上电话线
bind函数让我们的套接字和地址关联,但是要让别人打通电话,我们还需要接上电话线,让服务处于可接听的状态。
int listen(int listensockfd, int backlog);
- sockfd: 套接字描述符
- backlog: 表示已完成(
ESTABLISHED)且未accept的队列大小。- 这个参数的大小决定了可以接收的并发数目。这个参数越大,理论上并发数目也越大。但是参数过大 -> 系统资源消耗多。对于Linux系统来说,不允许对这个参数进行改变。
accept – 电话铃响
int accept(int listensockfd, struct sockaddr *_Nullable restrict addr,
socklen_t *_Nullable restrict addrlen);
- sockfd: listen套接字,这是通过bind,listen一系列操作得到的fd
- addr: 指向客户端地址的指针
- addrlen: 地址的大小
要注意有两个套接字,第一个是监听套接字listensockfd,其作为输入参数存在;第二个是返回的已连接套接字描述字。
connect – 拨打电话
上述的api为服务器api,现在我们来看看客户端的api。客户端和服务器端的链接建立,是通过connect函数完成的。这是connect的构建函数:
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
- sockfdfd: 由socket函数创建
- addr: 指向套接字地址的结构
- addrlen: 套接字地址结构大小
客户端在调用函数connect前不必非得调用bind,若需要的话,内核会确定源ip地址,并按照一定的算法选择一个临时端口作为源端口。
若为tcp套接字,那么调用connect函数将激发TCP三次握手的过程,而且只在连接建立成功或者出错的时候才返回。其中出错返回可能有以下几种情况:
- 三次握手无法建立,客户端发出的SYN包没有任何响应,于是返回
TIMEOUT错误。此类错误的常见原因是对应的服务端IP写错。 - 客户端收到了
RST应答,此时客户端会立即返回CONNECTION REFUSED错误。这种情况常见于客户端发送连接请求时端口写错。因为 RST 是TCP在发生错误时发送的一种TCP分节。- 产生
RST的 三个条件:- 目的地为某端口的SYN到达,但是该端口未被监听
- TCP想取消一个已有的连接
- TCP接收到一个根本不存在的连接上的分节
- 产生
- 客户端发出的SYN包在网络上返回了
destination unreachable, 即目的不可达错误。此类错误的常见原因是路由不通。 - 收发缓冲区
客户端在发送数据时可选择如下四个函数:
ssize_t write(int fd, const void *buf, size_t count);
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);
ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags);
write是常见的文件写函数,如果fd换成常用的文件描述符,就是普通的写文件函数。send用于tcp流发送数据,sendto用于udp发送数据。可指定选项发送带外数据。sendmsg指定多重缓冲区传输数据,以结构体msghdr的方式发送数据。
以上函数的使用场景不同。
缓冲区大小
当TCP三次握手成功,TCP连接建立后,os kernel 会为每一个连接创建配套的设置,如发送缓冲区。
在linux系统中发送缓冲区的大小受以下选项和行为控制:
- 由kernel控制,缓冲区的最小值,默认值和最大值:
cat /proc/sys/net/ipv4/tcp_wmem
4096 16384 4194304
- 由kernel控制,缓冲区的最大值:
cat /proc/sys/net/core/wmem_max
212992
- 由kernel控制,缓冲区的默认值
cat /proc/sys/net/core/wmem_default
212992
- 在socket选项中,可通过
setsockopt设置缓冲区大小为n
int send_buf_size = 20 * 1024 * 1024;
setsockopt(fd, SOL_SOCKET, SO_SEND_BUF, &set_buf_size, sizeof(int));
要注意的是:
通过setsockopt设置缓冲区大小为n,但是最大大小受/proc/sys/net/core/wmem_max控制。且实际上我们发现getsockopt获取的缓冲区大小为2*n,这是基于overhead解包的需求,会多预留空间。
源码的处理:
case so_sendbuf:
if (val > sysctl_wmem_max)
val = stsctl_wmen_max;
if ((val * 2) < SOCK_MIN_SENDBUF)
sk->sk_sndbuf = SOCK_MIN_SENDBUF;
else
sk->sk_sendbuf = val * 2;
写入数据
发送缓冲区的大小可以通过以上的设置改变,当我们的程序调用写入函数时,实际上做的事是将数据从user space拷贝到os kernel中的发送缓冲区中,而非将数据通过socket传出去。后续的部分由内核协议栈完成。
在写入缓冲区的时候有两种情况(非阻塞情况下):
- 内核缓冲区足够大,足以容纳所要发送的数据,那么自然返回成功。
- 要发送的数据长度超过了当下内核缓冲区的空闲大小,那么这个时候
send函数会返回已成功拷贝到缓冲区中的字节数。同时errno会被置为EAGAINorEWOULDBLOCK。
读取数据
让我们从最简单的read函数开始
ssize_t read(int fd, void *buffer, size_t size);
read函数要求os kernel从套接字fd读取最多size个字节,并将结果储存到buffer中。返回值告诉我们实际读取的字节数目。
特殊情况:
- ret == 0; 表示EOF(end of file), 这在网络中表示对端发送了FIN包,要处理断连的情况
- ret == -1; 表示出错。
相关问题
socket部分
- socket默认为阻塞调用,既然有阻塞调用,那么就有非阻塞调用。那么如何使用非阻塞调用呢?使用的场景在哪里呢?
答:非阻塞的场景:高性能服务器编程。所有的调用都不需要等待对方准备好了再返回,而是立即返回。那么怎么知道是否准备好了?
就是将fd注册到类似select或者epoll这样的io多路复用机制中,变多个fd阻塞为一个fd阻塞。那么只要有任何一个fd准备好了,select或者epoll都会返回。我们再从中取出fd做对应的操作即可。
- 客户端在发起connect调用之前,可以调用bind函数吗?
答:客户端可以bind指定固定端口来建立连接。
若没有bind则会产生一个随机的端口来完成连接请求。
- udp是否可以使用connect? udp使用connect和tcp有何不同?
答:udp可以使用connect。tcp协议使用connect是为了进行三次握手,而udp协议使用connect实际上不会向对端发送任何报文,而只是在本机建立起四元组的上下文。udp在使用connect后可以使用send来代替sendto,这是因为四元组是上下文已经建立,若未使用connect,则需要使用sendto显式指定对端的ip和port。
- write函数传入普通文件描述符和套接字描述符有什么区别?
答:1)对于普通文件描述符而言,一个fd代表一个打开了的文件句柄。通过调用write函数,os kernel帮我们往文件系统中不断地写入字节流。通常而言,写入的字节流大小和输入参数size的值是相等的,否则表示出错。
2)而对于套接字fd来说,它代表一个双向的连接,在套接字fd上调用write写入的字节数有可能比我们申请的要少(在缓冲区满的情况下)。
- udp单次传输的最大数据量是多少?
答:我们知道ip封包的总长度为两字节,即一个ip包最大长度为65535,那么减去一个ip头(20字节),减去一个udp头(8字节)= 65507字节。
- 在写入发送缓冲区时若缓冲区若数据为全部拷贝,如何解决发送数据不完整的问题?(即所谓半包问题)
答:由于我们已知成功拷贝到缓冲区的字节数,一个较好的处理方式是将剩余未拷贝的数据保存下来,然后监控该fd直到下次可写状态到来。再将其写入缓冲区。
- 能否把内核发送缓冲区的大小无限调大?
答:不行。
- 网卡一次发出去的数据表肯定也有一个最大长度,所以无论累计再多数据最后都是要分片发送的。
- 数据传输有时延要求,缓冲区里如果有数据的话也会持续进行消费,将缓冲区设置的太大反而浪费系统资源。
- 一段数据流从应用程序发送端,一直到应用程序接收端,总共经过多少次拷贝?
答:8次。主要体会tcp/ip分层的思想。
TCP协议部分
- 简单说说,为什么tcp连接需要三次握手?
答:本质上是因为tcp连接的双方都要确认各自收发消息的能力是正常的。
Client -- syn --> Server: 这是客户端第一次发送syn消息到服务端,此时client可以确认自己有发消息的能力。
Clinet <-- syn, ack -- Server: 服务器发送sync和ack到,此时server确认自己有收,发消息的能力,还需确认client有收消息的能力。
Clinet -- ack --> Server: 客户端确认自己有接收的能力。
- 如果握手过程中客户端的第三次应答,服务器没有收到,然后客户端开始发消息给服务器,这时候服务器和客户端的表现是什么?客户端会收到什么返回?
答:如果三次应答服务器没有收到,服务端连接没有建立,客户端连接建立了,发送的报文会被设置为连接RST。






