数据传输方式
SOCK_STREAM 面向连接 准确性/相对较慢 TCP
SOCK_DGRAM 无连接的传输方式 视频/音频 UDP
Server
1. 创建套接字 int sock = socket(AF_INET, SOCK_STREAM, 0);
2. 将套接字和IP、端口绑定 bind(sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
3. 进入监听状态,等待客户端请求 listen(…);
accept(…);
4. 接受客户端数据/向客户端发送数据 read(…)/write(…);
5. 关闭套接字 close();
Client
1. 创建套接字
2. 向服务器(特定的IP和端口)发送请求 connect(…);
3. 读取服务器传回的数据/向服务器发送数据 read/write(sock, char*, sizeof(…));
4. 关闭套接字
Windows vs Linux
1)Windows下的Socket程序依赖 Winsock.dll 或 ws2_32.dll, 必须提前加载。1
2
3
4
5
6
7
8
9
10
// 初始化DLL
WSADATA wsaData;
WSASetUp(MAKEWORD(2, 2), &wsaData); // MAKEWORD,WinSock规范版本号
...
...
...
// 终止使用DLL
WSACleanUp();
2)Linux使用 文件描述符 的概念,而Windows下使用 文件句柄 的而概念;Linux不区分socket文件和普通文件,而Windows区分;Linux下的 socket() 函数返回int,而Windows下返回 SOCKET 类型,也就是句柄。
3)Linux下使用 read/write 读写,而Windows下使用 recv()/send() 收发。
4)关闭socket时, Linux使用 close 函数,而Windows使用 closesocket 函数。
Linux下socket编程流程
在Linux中, “一切皆文件” ====> 文件描述符(文件句柄)。
套接字的工作流程(服务器端): 通过 socket() 系统调用创建一个套接字,它是系统分配给该服务器进程的类似文件描述符的资源,不能与其它的进程共享;服务器进程使用 bind() 系统调用给套接字命名,本地套接字的名字是 Linux 文件系统的文件名,一般放在 /tmp 或者 /usr/tmp 目录下;网络套接字的名字是与客户端相连接的特定网络有关的服务标识符,此标识符允许 Linux 将进入的针对特定端口号的连接转到正确的服务器进程。接下来,服务器进程开始等待客户端连接到这个命名套接字,调用 listen() 创建一个等待队列,以便存放来自客户端的进入连接。最后,服务器端通过 accept() 系统调用来接受客户端的连接,此时,会 产生一个与原来的命名套接字不同的新套接字,它仅用于与这个特定的客户端通信,而命名套接字则被保留下来继续处理其他客户的连接。
套接字的工作流程(客户端): 调用 socket() 创建一个未命名套接字,将服务器的命名套接字作为一个地址来调用 connect() 与服务器建立连接;一旦建立了连接,就可以 像使用底层文件描述符那样来使用套接字进行双向的数据通信。
创建socket
在Linux中使用
int socket(int af, int type, int protocol);
创建套接字。
1) af 为地址簇(Address Family),AF_INET 和 AF_INET6。
2) type 为数据传输方式,SOCK_STREAM 和 SOCK_DGRAM。
3) protocol 表示传输协议,IPPROTO_TCP 和 IPPROTO_UDP。
建立连接1
2
3// 把一个地址簇中的特定地址赋给socket
int bind(int sock, struct sockaddr* addr, socklen_t addrlen);
int connect(int sock, struct sockaddr* addr, socklen_t addrlen);
使用 sockaddr_in 结构体,再强制转换为 sockaddr 类型。1
2
3
4
5
6
7
8
9struct sockaddr_in{
sa_family_t sin_family; // Address Family,也就是地址类型
uint16_t sin_port; // 16位的端口号,通过 htons(0~65536) 函数获取
struct in_addr sin_addr; // 32位IP地址
char sin_zero[8]; // 不使用,一般用0填充,memset(...)
};
struct in_addr{
in_addr_t s_addr; // 32位IP地址,通过 inet_addr("xxx.xxx.xxx.xxx") 获取
};
客户端不用指定地址(ip+端口号),有系统会自动分配一个端口号和自身的ip地址组合;客户端不会调用 bind() 函数,而是在 connect() 时由系统随机生成一个。
监听和响应1
int listen(int sock, int backlog);
当套接字正在处理客户端请求时,如果有新的请求进来,套接字没法处理,只能把它放进缓冲区,待当前请求处理完毕后,再从缓冲区中读取出来处理。这个缓冲区,称为请求队列。当请求队列满时(>backlog),求不再接收新的请求。
listen 只是让套接字处于监听状态,并没有接收请求。接收请求需要使用 accept。1
int accept(int sock, struct sockaddr* addr, socklen_t* addrlen);
返回一个新的套接字来和客户端通信,addr 保存了客户端的IP地址和端口号,而 sock 是服务端的套接字。
listen 后面的代码会继续执行,直到遇到 accept,accept 会阻塞程序执行,直到有新的请求到来。
接收和发送
Linux不区分套接字文件和普通文件,使用 read/write 函数可以从套接字中读取数据和向套接字中写入数据。1
ssize_t write(int fd, const void* buf, size_t nbytes);
将缓冲区 buf 中的 nbytes 个字节写入文件 fd,成功则返回写入的字节数,失败则返回-1。1
ssize_t read(int fd, void* buf, size_t nbytes);
从 fd 文件中读取 nbytes 个字节并保存到缓冲区 buf。
可以通过 man文档 查看以下函数: man function_name1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18// read/write通用函数
ssize_t read(int fd, void* buf, size_t nbytes);
ssize_t write(int fd, const void* buf, size_t nbytes);
// socket接收/发送函数
ssize_t send(int sockfd, const void* buf, size_t len, int flags);
ssize_t recv(int sockfd, void* buf, size_t len, int flags);
ssize_t sendto(int sockfd, const void* buf, size_t len, int flags,
const struct sockaddr* src_addr, socklen_t addrlen);
ssize_t recvfrom(int sockfd, void* buf, size_t len, int flags,
struct sockaddr* dest_addr, socklen_t* addrlen);
ssize_t sendmsg(int sockfd, const struct msghdr* msg, int flags);
ssize_t recvmsg(int sockfd, struct msghdr* msg, int flags);
TCP 网络通讯
三次握手建立连接
客户端调用 socket 创建套接字后,因为没有建立连接,处于 CLOSED 状态;服务器端调用 listen() 函数后,套接字进入 LISTEN 状态,开始监听客户端请求。
1)客户端调用 connect 函数,TCP 协议会组建一个数据包,并设置 SYN 标志位,表示该数据包用于建立同步连接的;生成一个随机数,填充 Seq 字段 ,表示该数据包的序号;完成这些工作,开始向服务端发送数据,客户端进入 SYN_SEND 状态。
2)服务器端收到数据包,检测到已经设置了 SYN 标志位,就知道是客户端发来的建立连接的”请求包”;服务器端也组建一个数据包,并设置 SYN 和 ACK 标志位,SYN 表示该数据包用来建立连接,ACK 用来确认收到了刚才客户端发送的数据包。
服务器端生成一个随机数,填充 Seq 字段,用客户端的 Seq + 1 填充 ACK 字段,服务器将数据包发出,进入 SYN-RECV 状态。
3)客户端收到数据包,检测到已经设置了 SYN 和 ACK 标志位,就知道这是服务器端发来的”确认包”。客户端会检测”确认号 ACK “字段,看它的位置是否为 Seq + 1,如果是就说明连接成功。接下来,客户端会继续组建数据包并设置 ACK 标志位,表示客户端正确接收了服务器发来的”确认包”;同时,用刚从服务器端发来的”数据包序号 + 1”填充 ACK 字段。
客户端将数据包发出,进入 ESTABLISHED 状态,表示连接已经成功建立。
4)服务器端接收到数据包,检测到已设置 ACK,就知道是客户端发来的”确认包”;检测 ACK 字段是否为 “Seq + 1”,是就说明连接建立成功,服务器进入 ESTABLISHED 状态。
5)客户端和服务器端都进入了 ESTABLISHED 状态,连接建立成功,接下来就可以收发数据了。
三次握手的关键是确认对方收到了自己的数据包,这个目标通过 ACK 字段来实现。
传输过程
为了保证数据准确到达,目标机器在收到数据包(包括 SYN 包、FIN 包、普通数据包)后,必须立即回传 ACK 包,这样发送方才能确认数据传输成功。
ACK 号 = Seq 号 + 传递的字节数 + 1
重传超时时间 RTO,Retransmission Time Out
重传次数 3次
发送方只有在收到对方的 ACK 确认包后,才会清空 输出缓冲区 中的数据。
四次握手断开连接
建立连接非常重要,它是数据传输的前提;断开连接同样重要,它让计算机释放不再使用的资源;如果连接不能正常断开,不仅会造成数据传输错误,还会导致套接字不能关闭,持续占用资源,如果开发量高,服务器压力堪忧!
建立连接后,客户端和服务器端都处于 ESTABLISHED 状态,这时,客户端发起断开连接的请求。
1)客户端调用 close 函数后,向服务器发送 FIN 数据包,进入 FIN_WAIT_1 状态。
2)服务器端收到数据包后,检测到设置了 FIN 标志位,知道要断开连接,于是向客户端发送”确认包”,进入 CLOSE_WAIT 状态;
服务器收到请求后并不是立即断开连接,而是先向客户端发送”确认包”,告诉它我知道了,我需要准备下才能断开连接。
3)客户端收到”确认包”后进入 FIN_WAIT_2 状态,等待服务器端准备完毕后再次发送数据包。
4)等待片刻后,服务器准备完毕,可以断开连接,于是主动向客户端发送 FIN 包,告诉它我准备好了,断开连接吧,然后进入 LAST_ACK 状态。
5)客户端收到服务器端的 FIN 包后,再向服务器发送 ACK,告诉它你断开连接吧,然后进入 TIME_WAIT 状态。
6)服务器端收到客户端的 ACK 后,就断开连接,关闭套接字,进入 CLOSED 状态,maybe等待下一个客户端的连接请求。
客户端最后一次向服务器回传 ACK 包时,有可能会因为网络问题导致服务器收不到!!!
1)服务器会再次发送 FIN 包,如果这时客户端关闭了连接,那么服务器无论如何也收不到 ACK 包了,所以客户端需要稍等片刻,确认对方收到 ACK 包后才能进入 CLOSED 状态。
2)TIME_WAIT 要等待 2xMSL(Maximum Segment Lifetime,报文最大生存时间),才会进入 CLOSED 状态: ACK 包到达服务器端需要 MSL 时间,服务器重传的 FIN 包需要 MSL 时间,如果 2MSL 后还未收到服务器重传的 FIN 包,就相信服务器端应该已经收到 ACK 包了。
close 一个 TCP socket 的缺省行为是把该 socket 标记为已关闭,然后立即返回到调用进程;该套接字不能再由调用进程使用,也就是说不能再作为 read/write 的第一个参数。
close 操作只是使相应 socket 描述符的引用计数 -1,只有当引用计数为0的时候,才会触发 TCP 客户端向服务器端发送终止连接的要求。
shutdown断开连接
close 函数意味着完全断开连接,既不能发送数据,也不能接收数据。
close 函数用来关闭套接字,将套接字描述符(或句柄)从内存清除,之后再也不能使用该套接字,与C语言中的 fclose 类似。应用程序关闭套接字后,与该套接字相关的连接和缓存也失去了意义,TCP协议会自动触发关闭连接的操作。
shutdown 用来关闭连接,而不是套接字,不管调用多少次 shutdown,套接字依然存在,直到调用 close 函数将套接字从内存清除。
调用 close 关闭套接字时,或调用 shutdoen 关闭输出流时,都会向对方发送 FIN包。默认情况下,close 会立即向网络中发送 FIN包,不管输出缓冲区中是否还有数据;而 shutdown 会等输出缓冲区中的数据传输完毕再发送 FIN包。也就意味着,调用 close 函数将丢失输出缓冲区中的数据,而调用 shutdown 函数不会。1
int shutdown(int sockfd, int howto);
howto 有以下取值:
SHUT_RD:断开输入流,套接字无法接收数据,即使输入缓冲区收到数据也被抹去,无法调用输入相关函数。
SHUT_WR:断开输出流,套接字无法发送数据,但如果输出缓冲区中还有未传输的数据,则将传递到目标主机。
SHUT_RDWR:同时断开I/O流,SHUT_RD + SHUT_WR。
迭代服务器端和客户端
server.cpp 中调用 closesocket() 函数不仅会关闭服务器端的socket,还会通知客户端连接已断开,客户端也会清理socket相关资源,所以 client.cpp 需要将 socket() 函数放在 while循环 内部,因为每次请求完毕,客户端都会清理socket,下次发起请求时需要重新创建。
socket缓冲区
每个socket被创建后,都会分配两个缓冲区,输入缓冲区和输出缓冲区。
write/send 并不立即向网络中传输数据,而是先将数据写入缓冲区中,再由TCP协议将数据从缓冲区发送到目标机器。一旦数据写入缓冲区,函数就可以成功返回,不管他们有没有到达目标机器,也不管他们何时被发送到网络,这些都是TCP协议负责的事情。
TCP协议独立于 write/send 函数,数据有可能刚被写入缓冲区就发送到网络,也可能在缓冲区中不断积压,多次写入的数据被一次性发送到网络,这取决于当时的网络情况、当前线程是否空闲等诸多因素,不由程序员控制。
read/recv 函数也是如此,也从输入缓冲区读取数据,而不是直接从网络中读取。
这些I/O缓冲区特性可整理如下:
I/O缓冲区在每个TCP套接字中单独存在;
I/O缓冲区在创建套接字时自动生成;
即使关闭套接字也会继续传送输出缓冲区中遗留的数据(normally close tries to complete this transmission);
关闭套接字将丢失输入缓冲区中的数据。
输入输出缓冲区的默认大小一般都是8K,可以通过 getsockopt 函数获取。
阻塞模式
所谓阻塞,就是上一步动作没有完成,下一步动作将暂停,知道上一步动作完成后才能继续,以保持同步性。
TCP套接字默认是阻塞模式(可更改为非阻塞模式)。
当使用 write/send 发送数据时:
1)检查缓冲区,如果可用空间长度小于要发送的数据,write/send 会被阻塞,知道缓冲区中数据被发送到目标机器,腾出足够的空间才能唤醒。
2)如果TCP协议正在向网络发送数据,输出缓冲区会被锁定,不允许写入,write/send 被阻塞,直到数据发送完毕,缓冲区解锁,才被唤醒。
3)如果写入的数据大于缓冲区的最大长度,将分批写入。
4)直到所有数据被写入缓冲区,write/send 函数才能返回。
当使用 read/recv 读取数据时:
1)检查缓冲区,如果缓冲区中有数据,那么就读取;否则函数会被阻塞,直到网络上有数据到来。
2)如果要读取的数据长度小于缓冲区中的数据长度,那么就不能一次性将缓冲区中的所有数据读出;剩余数据将不断积压,直到 read/recv 函数再次读取。
3)直到读取到数据后, read/recv 函数才会返回,否则就一直被阻塞。
TCP的粘包问题
客户端发送的多个数据包被当做一个数据包接收,也称数据的无边界性。read/recv 函数不知道数据包的开始或结束标志(实际上也没有任何开始或结束标志),只把它当做连续的数据流处理。
server.cpp 中有 sleep(10); 让程序暂停执行10秒。在这段时间内,client连续发送三个数据包。由于server被阻塞,数据只能堆积在缓冲区。10秒后,server开始运行,从缓冲区中一次性读出所有积压的数据并返回客户端。1
void Sleep(DWORD dwMilliSeconds);
client.cpp 执行 read/recv 函数,由于输入缓冲区中没有数据,所以会被阻塞。
网络字节序 vs 主机字节序
little endian 和 big endian 系统解析和保存数据的方式不同,通信时会发生数据解析错误。在发送数据前,要将数据转换为统一格式(Network Byte Order),统一为大端序(高位字节在前)。收到数据后先转换为自己的格式再解析。
// 端口号转换函数
htons:host short ===> network short(2个字节)
ntohs:network short ===> host short
// IP地址转换函数
htonl:host long ===> network long(4个字节)
ntohl:network long ===> host long
// 隐式转换函数
inet_addr:除了将字符串转换为32位整数,同时还进行网络字节序的转换。
write:发送数据时,TCP协议会自动转换为网络字节序。
通过域名获取IP地址
域名仅仅是IP地址的一个助记符,目的是方便记忆,通过域名并不能找到目标计算机,通信之前必须要将域名转换成IP地址。1
2
3
4
5
6
7
8struct hostent* gethostbyname(const char* hostname);
struct hostent{
char* h_name; // official name
char** h_aliases; // alias list
int h_addrtype; // host address type, AF_INET/AF_INET6,即IPv4或IPv6
char** h_length; // address length, 4/16
char** h_addr_list; // address list,用于用户较多的服务器,可能会分配多个IP地址给同一个域名,进行均衡负载
}
TCP vs UDP
UDP 是非连接的传输协议,没有建立连接和断开连接的过程,它只是简单地把数据丢到网络中,也不需要 ACK包 确认。
UDP 的可靠性虽然比不上 TCP,但也不会像想象中那么频繁地发生数据损毁,在更加重视传输效率而非可靠性的情况下,UDP 是一种很好的选择,比如视频通信或者音频通信。
TCP 的速度无法超越 UDP,但在收发某些类型的数据时有可能接近 UDP。
没有连接
不必调用 listen 或 accept 函数,只有创建套接字和数据交换的过程。
只需一个套接字
TCP 的套接字是一对一的关系,如果要向10个客户端提供服务,除了负责监听的套接字外,还需要创建10个套接字。但在 UDP,不管是服务器端还是客户端都只需要1个套接字,就可以向任意主机传送数据。1
2
3
4
5// flags: 可选项参数,若没有可传递0
ssize_t sendto(int sockfd, const void* buf, size_t len, int flags,
const struct sockaddr* src_addr, socklen_t addrlen);
ssize_t recvfrom(int sockfd, void* buf, size_t len, int flags,
struct sockaddr* dest_addr, socklen_t* addrlen);
创建好 TCP 套接字后,传输数据时无需再添加地址信息,因为TCP套接字将保持与对方套接字的连接,即TCP套接字知道目标地址信息。但UDP套接字不会保持连接状态,每次传输数据都要添加目标地址信息(如上述 sendto、recvfrom 函数)。
文件传输
1. 当读取到文件末尾,fread 函数会返回0。
2. 读取完缓冲区中的数据,read 并不返回0,而是被阻塞,直到缓冲区中再次有数据。
收到 FIN包 后,知道对方不会再向自己传输数据,此时调用 read/recv 函数,如果缓冲区中没有数据,就会返回0,表示读到了”socket文件的末尾”。
3. close 函数会使输出缓冲区中的数据失效,文件内容很有可能没有传输完毕,连接就断开了。
shutdown 函数会等待输出缓冲区中的数据传输完毕。
文件描述符 vs 文件指针
套接字描述符:其实就是一个整数,我们最熟悉的句柄是:0(stdin)、1(stdout)、2(stderr)。0、1、2是整数表示的,对应的 FILE* 结构的表示就是 stdin…
文件描述符:在Linux系统中打开文件就会获得文件描述符,它是一个很小的整数,每个进程在 PCB(Process Control Block) 中保存着一份文件描述符表,文件描述符就是这个表的索引,每个表项都有一个指向已打开文件的指针。
文件指针:C语言中使用文件指针作为I/O的句柄,文件指针指向进程用户区中的一个被称为 FILE结构 的数据结构,FILE结构 包括一个缓冲区和一个文件描述符,而文件描述符是文件描述表的索引,因此某种意义上说 文件指针 就是句柄的句柄(在Windows系统上,文件描述符被称作文件句柄)。
进程间通讯(IPC)
本地的进程间通讯(IPC)有很多种方式,但可以归结为下面4类:
消息传递(管道、FIFO、消息队列)
同步(互斥量、条件变量、读写锁、文件和写记录锁、信号量)
共享内存(匿名的和具名的)
远程过程调用(Solaris门 和 Sun RPC)
网络中进程之间如何通信?
首要解决的问题是,如何唯一标识一个进程,否则通信无从谈起!
在本地可以通过 进程PID 来唯一标识一个进程,但是在网络中这是行不通的。
TCP/IP 协议簇已经帮我们解决了这个问题,网络层的”ip地址”可以唯一标识网络中的主机,而传输层的”协议+端口”可以唯一标识主机中的应用程序(进程)。
利用三元组(IP地址,协议,端口)就可以标识网络的进程了,网络中的进程通信就可以利用这个标识与其他进程进行交互。
socket是”open—write/read—close”模式的一种实现。
TCP/IP协议存在于OS中,网络服务通过OS提供,在OS中增加支持TCP/IP的系统调用—- Berkeley套接字,如socket、connect、send、recv等API。套接字API最初是作为UNIX操作系统的一部分而开发的,所以套接字API与系统的其他I/O设备集成在一起,这些接口的实现都是内核来实现的。
套接字是一种进程间通信的方法,不同于其他进程间通信方法的是,它并不局限于同一台计算机的资源(如文件系统控件、共享内存或者消息队列等)。套接字可以认为是对 管道概念 的一种扩展—-一台机器上的进程可以使用套接字与另一台机器上的进程通讯(客户与服务器可以分散在网络中);同一台机器上的进程间也可以用套接字通信。套接字与管道的区别: 明确区分客户端与服务端,可以将多个客户连接到一个服务器。