瞎谈 Socket 编程(二)

数据传输方式

  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.dllws2_32.dll, 必须提前加载。

1
2
3
4
5
6
7
8
9
10
#pragma comment(lib, "xxx.dll")	// 使用#pragma命令,在编译时加载

// 初始化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_INETAF_INET6
  2) type 为数据传输方式,SOCK_STREAMSOCK_DGRAM
  3) protocol 表示传输协议,IPPROTO_TCPIPPROTO_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
9
struct 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 后面的代码会继续执行,直到遇到 acceptaccept 会阻塞程序执行,直到有新的请求到来。

接收和发送
  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_name

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// read/write通用函数
#include <unistd.h>
ssize_t read(int fd, void* buf, size_t nbytes);
ssize_t write(int fd, const void* buf, size_t nbytes);

// socket接收/发送函数
#include <sys/types.h>
#include <sys/socket.h>
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 标志位,就知道是客户端发来的建立连接的”请求包”;服务器端也组建一个数据包,并设置 SYNACK 标志位,SYN 表示该数据包用来建立连接,ACK 用来确认收到了刚才客户端发送的数据包。
    服务器端生成一个随机数,填充 Seq 字段,用客户端的 Seq + 1 填充 ACK 字段,服务器将数据包发出,进入 SYN-RECV 状态。
   3)客户端收到数据包,检测到已经设置了 SYNACK 标志位,就知道这是服务器端发来的”确认包”。客户端会检测”确认号 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 endianbig 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
8
struct 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
  没有连接
   不必调用 listenaccept 函数,只有创建套接字和数据交换的过程。
  只需一个套接字
   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套接字不会保持连接状态,每次传输数据都要添加目标地址信息(如上述 sendtorecvfrom 函数)。

文件传输

  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设备集成在一起,这些接口的实现都是内核来实现的。

   套接字是一种进程间通信的方法,不同于其他进程间通信方法的是,它并不局限于同一台计算机的资源(如文件系统控件、共享内存或者消息队列等)。套接字可以认为是对 管道概念 的一种扩展—-一台机器上的进程可以使用套接字与另一台机器上的进程通讯(客户与服务器可以分散在网络中);同一台机器上的进程间也可以用套接字通信。套接字与管道的区别: 明确区分客户端与服务端,可以将多个客户连接到一个服务器

文章目录
  1. 1. 数据传输方式
  2. 2. Windows vs Linux
  3. 3. Linux下socket编程流程
  4. 4. TCP 网络通讯
  5. 5. 迭代服务器端和客户端
  6. 6. socket缓冲区
  7. 7. 阻塞模式
  8. 8. TCP的粘包问题
  9. 9. 网络字节序 vs 主机字节序
  10. 10. 通过域名获取IP地址
  11. 11. TCP vs UDP
  12. 12. 文件传输
  13. 13. 文件描述符 vs 文件指针
  14. 14. 进程间通讯(IPC)