1、Socket是什么呢?

  1. **Socket是应用层与TCP/IP协议族通信的中间软件抽象层,它是一组接口**。在设计模式中,Socket其实就是一个门面模式,它把复杂的TCP/IP协议族隐藏在Socket接口后面,对用户来说,一组简单的接口就是全部,让Socket去组织数据,以符合指定的协议。<br />![image.png](https://cdn.nlark.com/yuque/0/2022/png/25523101/1651649199060-a4173180-10a6-4652-9983-97c8533fb58c.png#clientId=u763f2efc-95c5-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=476&id=u659277c6&margin=%5Bobject%20Object%5D&name=image.png&originHeight=476&originWidth=542&originalType=binary&ratio=1&rotation=0&showTitle=false&size=65574&status=done&style=none&taskId=ucb64f524-27f9-4052-8deb-469e442036e&title=&width=542)

2、Socket编程原理

服务器端先初始化Socket,然后与端口绑定(bind),对端口进行监听(listen),调用accept阻塞,等待客户端连接。在这时如果有个客户端初始化一个Socket,然后连接服务器(connect),如果连接成功,这时客户端与服务器端的连接就建立了。客户端发送数据请求,服务器端接收请求并处理请求,然后把回应数据发送给客户端,客户端读取数据,最后关闭连接,一次交互结束。
image.png

3、如何标识网络中具体是哪个进程?

网络层的“ip地址”:可以唯一标识网络中的主机,
传输层的“协议+端口”:可以唯一标识主机中的应用程序(进程)。
这样利用三元组(ip地址,协议,端口)就可以标识网络的进程了,网络中的进程通信就可以利用这个标志与其它进程进行交互。

4、socket的基本操作

Unix/Linux基本哲学之一就是“一切皆文件”,都可以用“打开open –> 读写write/read –> 关闭close”模式来操作。socket就是该模式的一个实现,socket可以理解为一种特殊的文件,一些socket函数就是对其进行的操作(读/写IO、打开、关闭),这些函数我们在后面进行介绍。

4.1、socket()函数

int **socket**(int domain, int type, int protocol);
socket函数对应于普通文件的打开操作。普通文件的打开操作返回一个文件描述字,而socket()用于创建一个socket描述符(socket descriptor),它唯一标识一个socket。这个socket描述字跟文件描述字一样,后续的操作都有用到它,把它作为参数,通过它来进行一些读写操作。

  • domain:即协议域,又称为协议族(family)。常用的协议族有,AF_INET、AF_INET6、AF_LOCAL(或称AF_UNIX,Unix域socket)、AF_ROUTE等等。协议族决定了socket的地址类型,在通信中必须采用对应的地址,如AF_INET决定了要用ipv4地址(32位的)与端口号(16位的)的组合、AF_UNIX决定了要用一个绝对路径名作为地址。
  • type:指定socket类型。常用的socket类型有,SOCK_STREAM(TCP)、SOCK_DGRAM(UDP)、SOCK_RAW、SOCK_PACKET、SOCK_SEQPACKET等等。
  • protocol:顾名思义,就是指定协议。套接口所用的协议。如调用者不想指定,可用0。常用的协议有,IPPROTO_TCP、IPPROTO_UDP、IPPROTO_STCP、IPPROTO_TIPC等,它们分别对应TCP传输协议、UDP传输协议、STCP传输协议、TIPC传输协议。

注意:并不是上面的type和protocol可以随意组合的,如SOCK_STREAM不可以跟IPPROTO_UDP组合。当protocol为0时,会自动选择type类型对应的默认协议。
当我们调用socket创建一个socket时,返回的socket描述字,它存在于协议族(address family,AF_XXX)空间中,但没有一个具体的地址。如果想要给它赋值一个地址,就必须调用bind()函数,否则就当调用connect()、listen()时系统会自动随机分配一个端口。

4.2、bind()函数— 将socket 绑定到具体的ip和port

正如上面所说bind()函数把一个地址族中的特定地址赋给socket。例如对应AF_INET、AF_INET6就是把一个ipv4或ipv6地址和端口号组合赋给socket。
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
函数的三个参数分别为:

  • sockfd:即socket描述字,它是通过socket()函数创建了,唯一标识一个socket。bind()函数就是将给这个描述字绑定一个名字。
  • addr:一个const struct sockaddr *指针,指向要绑定给sockfd的协议地址。这个地址结构根据地址创建socket时的地址协议族的不同而不同,如ipv4对应的是: ```cpp struct sockaddr_in { sa_family_t sin_family; in_port_t sin_port;
    struct in_addr sin_addr;
    }; struct in_addr { uint32_t s_addr;
    };

// ipv6对应的是: struct sockaddr_in6 { sa_family_t sin6_family;
in_port_t sin6_port;
uint32_t sin6_flowinfo;
struct in6_addr sin6_addr;
uint32_t sin6_scope_id;
};

struct in6_addr { unsigned char s6_addr[16];
};

  1. - **addrlen**:对应的是地址的长度。
  2. 通常服务器在启动的时候都会绑定一个众所周知的地址(如ip地址+端口号),用于提供服务,客户就可以通过它来接连服务器;而客户端就不用指定,有系统自动分配一个端口号和自身的ip地址组合。**这就是为什么通常服务器端在listen之前会调用****bind****(),而客户端就不会调用,而是在****connect****()时由系统随机生成一个。**
  3. **网络字节序与主机字节序:**<br />**主机字节序**就是我们平常说的大端和小端模式:不同的CPU有不同的字节序类型,这些字节序是指整数在内存中保存的顺序,这个叫做主机序。引用标准的Big-EndianLittle-Endian的定义如下:<br />大端"和"小端"表示多字节值的哪一端存储在该值的起始地址(内存的低地址位)处。<br /> a)** Little-Endian**就是低位字节排放在内存的低地址端,高位字节排放在内存的高地址端。<br /> b) **Big-Endian**就是高位字节排放在内存的低地址端,低位字节排放在内存的高地址端。<br />**网络字节序(大端模式)**:<br />网络协议规定**接收到得第一个字节是高字节,存放到低地址**,所以发送时会首先去低地址取数据的高字节。<br />4个字节的32 bit值以下面的次序传输:首先是0~7bit,其次8~15bit,然后16~23bit,最后是24~31bit。这种传输次序称作大端字节序。**由于TCP/IP首部中所有的二进制整数在网络中传输时都要求以这种(大端字节序)次序,因此它又称作网络字节序。**字节序,顾名思义字节的顺序,就是大于一个字节类型的数据在内存中的存放顺序,一个字节的数据没有顺序的问题了。<br />所以: 在将一个地址绑定到socket的时候,请先将**主机字节序转换成为网络字节序**,而不要假定主机字节序跟网络字节序一样使用的是Big-Endian。由于 这个问题曾引发过血案!公司项目代码中由于存在这个问题,导致了很多莫名其妙的问题,所以请谨记对主机字节序不要做任何假定,务必将其转化为网络字节序再赋给socket。
  4. <a name="lrNRW"></a>
  5. #### 4.3、listen()、connect()函数
  6. 如果作为一个服务器,在调用socket()、bind()之后就会调用listen()来监听这个socket,如果客户端这时调用connect()发出连接请求,服务器端就会接收到这个请求。<br />`int listen(int sockfd, int backlog); `<br />`int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen); `<br />listen函数的第一个参数即为要监听的socket描述字,第二个参数为相应socket可以排队的**最大连接个数**。 socket()函数创建的socket默认是一个主动类型的,listen函数将socket变为被动类型的,等待客户的连接请求。<br />connect函数的第一个参数即为客户端的socket描述字,第二参数为服务器的socket地址,第三个参数为socket地址的长度。**客户端通过调用connect函数来建立与TCP服务器的连接**。
  7. <a name="EyBF2"></a>
  8. #### 4.4、accept()函数
  9. TCP服务器端依次调用socket()、bind()、listen()之后,就会监听指定的socket地址了。TCP客户端依次调用socket()、connect()之后就向TCP服务器发送了一个连接请求。TCP服务器监听到这个请求之后,就会调用accept()函数取接收请求,这样连接就建立好了。之后就可以开始网络I/O操作了,即类同于普通文件的读写I/O操作。<br />`int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen); `<br />accept函数的第一个参数为服务器的socket描述字,第二个参数为指向struct sockaddr *的指针,用于返回客户端的协议地址,第三个参数为协议地址的长度。如果accpet成功,那么其返回值是由内核自动生成的一个全新的描述字,代表与返回客户的TCP连接。<br />注意:accept的第一个参数为服务器的socket描述字,是服务器开始调用socket()函数生成的,称为监听socket描述字;而accept函数返回的是已连接的socket描述字。一个服务器通常通常仅仅只创建一个监听socket描述字,它在**该服务器的生命周期内一直存在**。内核为每个由**服务器进程**接受的客户连接创建了一个已连接socket描述字,当服务器完成了对某个客户的服务,相应的已连接socket描述字就被关闭。
  10. <a name="RxcN8"></a>
  11. #### 4.5、read()、write()等函数
  12. 万事具备只欠东风,至此服务器与客户已经建立好连接了。可以调用网络I/O进行读写操作了,即实现了网咯中不同进程之间的通信!网络I/O操作有下面几组:
  13. - read()/write()
  14. - recv()/send()
  15. - readv()/writev()
  16. - recvmsg()/sendmsg()
  17. - recvfrom()/sendto()
  18. 网上推荐使用recvmsg()/sendmsg()函数,这两个函数是最通用的I/O函数,实际上可以把上面的其它函数都替换成这两个函数。它们的声明如下:
  19. ```cpp
  20. ssize_t read(int fd, void *buf, size_t count);
  21. ssize_t write(int fd, const void *buf, size_t count);
  22. ssize_t send(int sockfd, const void *buf, size_t len, int flags);
  23. ssize_t recv(int sockfd, void *buf, size_t len, int flags);
  24. ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
  25. const struct sockaddr *dest_addr, socklen_t addrlen);
  26. ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
  27. struct sockaddr *src_addr, socklen_t *addrlen);
  28. ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags);
  29. ssize_t recvmsg(int sockfd, struct msghdr *msg, int flags);

read函数是负责从fd中读取内容。当读成功时,read返回实际所读的字节数,如果返回的值是0表示已经读到文件的结束了,小于0表示出现了错误。如果错误为EINTR说明读是由中断引起的,如果是ECONNREST表示网络连接出了问题。
write函数将buf中的nbytes字节内容写入文件描述符fd.成功时返回写的字节 数。失败时返回-1,并设置errno变量。在网络程序中,当我们向套接字文件描述符写时有俩种可能。
1)write的返回值大于0,表示写了部分或者是 全部的数据。
2)返回的值小于0,此时出现了错误。我们要根据错误类型来处理。如果错误为EINTR表示在写的时候出现了中断错误。如果为EPIPE表示 网络连接出现了问题(对方已经关闭了连接)。

4.6、close()函数

在服务器与客户端建立连接之后,会进行一些读写操作,完成了读写操作就要关闭相应的socket描述字,好比操作完打开的文件要调用fclose关闭打开的文件。
int close(int fd);
close一个TCP socket的缺省行为时把该socket标记为以关闭,然后立即返回到调用进程。该描述字不能再由调用进程使用,也就是说不能再作为read或write的第一个参数。
注意:close操作只是使相应socket描述字的引用计数-1,只有当引用计数为0的时候,才会触发TCP客户端向服务器发送终止连接请求。

4.7、其他socket可能用到的函数

#include <sys/socket.h>
int getsockopt(int sockfd, int level, int optname, void *optval, socklen_t *optlen)
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t *optlen)
setsockopt():可以设置接收发送buffer的大小,和超时时间等。
(1)、closesocket(一般不会立即关闭而经历TIME_WAIT的过程)后想继续重用该socket:
int optval = 1;
setsockopt(s, SOL_SOCKET, SO_REUSEADDR, (const void *)&optval , sizeof(int));
(2)、设置接收发送buffer的大小
setsockopt(s, SOL_SOCKET, SO_RCVBUF, (char*)&buf_siz, sizeof(buf_siz));
setsockopt(s, SOL_SOCKET, SO_SNDBUF, (char*)&buf_siz, sizeof(buf_siz));

#include <sys/select.h>
int select( int nfds, fd_set FAR* readfds, fd_set * writefds, fd_set * exceptfds, const struct timeval * timeout);确定一个或多个套接口的状态,如需要则等待。可同时监视多个文件描述符(套接字)。
//调用select函数. 若有监视的句柄有数据变化,则返回>0的整数;如果没有数据变化而引发超时,返回0.
nfds:是一个整数值,是指集合中所有文件描述符的范围,即所有文件描述符的最大值加1,不能错!
readfds:(可选)指针,指向一组等待可读性检查的套接口
writefds:(可选)指针,指向一组等待可写性检查的套接口。
exceptfds:(可选)指针,指向一组等待错误检查的套接口。
timeout:select()最多等待时间,对阻塞操作则为NULL

select机制中的数据结构操作:
fd_set set; /struct fd_set可以理解为一个集合,这个集合中存放的是文件描述符(filedescriptor),即文件句柄/
FD_ZERO(&set); /将set清零使集合中不含任何fd,fd为socket句柄/
FD_SET(fd, &set); /将fd加入set集合/
FD_CLR(fd, &set); /将fd从set集合中清除/
FD_ISSET(fd, &set); /在调用select()函数后,用FD_ISSET来检测fd是否在set集合中,当检测到fd在set中则返回真,否则,返回假(0)/

5、socket中TCP的三次握手建立连接详解

我们知道tcp建立连接要进行“三次握手”,即交换三个分组。大致流程如下:

  • 客户端向服务器发送一个SYN J
  • 服务器向客户端响应一个SYN K,并对SYN J进行确认ACK J+1
  • 客户端再想服务器发一个确认ACK K+1

只有就完了三次握手,但是这个三次握手发生在socket的那几个函数中呢?请看下图:
image.png
从图中可以看出,当客户端调用connect时,触发了连接请求,向服务器发送了SYN J包,这时connect进入阻塞状态;服务器监听到连接请求,即收到SYN J包,调用accept函数接收请求向客户端发送SYN K ,ACK J+1,这时accept进入阻塞状态;客户端收到服务器的SYN K ,ACK J+1之后,这时connect返回,并对SYN K进行确认;服务器收到ACK K+1时,accept返回,至此三次握手完毕,连接建立。
总结:客户端的connect在三次握手的第二次返回,而服务器端的accept在三次握手的第三次返回。

6、 socket中TCP的四次握手释放连接详解

上面介绍了socket中TCP的三次握手建立过程,及其涉及的socket函数。现在我们介绍socket中的四次握手释放连接的过程,请看下图:
image.png
图示过程如下:

  • 某个应用进程首先调用close主动关闭连接,这时TCP发送一个FIN M;
  • 另一端接收到FIN M之后,执行被动关闭,对这个FIN进行确认。它的接收也作为文件结束符传递给应用进程,因为FIN的接收意味着应用进程在相应的连接上再也接收不到额外数据;
  • 一段时间之后,接收到文件结束符的应用进程调用close关闭它的socket。这导致它的TCP也发送一个FIN N;
  • 接收到这个FIN的源发送端TCP对它进行确认。

这样每个方向上都有一个FIN和ACK。

7、 TCP 三次握手和四次握手详细过程?

image.png
注:seq:”sequance”序列号;ack:”acknowledge”确认号;SYN:”synchronize”请求同步标志;
ACK:”acknowledge”确认标志”;FIN:”Finally”结束标志。
TCP连接建立过程(三次握手):首先Client端发送连接请求报文,Server段接受连接后回复ACK报文,并为这次连接分配资源。Client端接收到ACK报文后也向Server段发生ACK报文,并分配资源,这样TCP连接就建立了。

  1. client 发送 Sync 连接请求报文
  2. server 接受连接请求之后,返回Sync+ACK,并为此次链接分配资源。
  3. client 接收之后,返回ACK报文,为此次链接分配资源。

TCP连接断开过程(四次握手):假设Client端发起中断连接请求,也就是发送FIN报文。Server端接到FIN报文后,意思是说”我Client端没有数据要发给你了”,但是如果你还有数据没有发送完成,则不必急着关闭Socket,可以继续发送数据。所以你先发送ACK,”告诉Client端,你的请求我收到了,但是我还没准备好,请继续你等我的消息”。这个时候Client端就进入FIN_WAIT状态,继续等待Server端的FIN报文。当Server端确定数据已发送完成,则向Client端发送FIN报文,”告诉Client端,好了,我这边数据发完了,准备好关闭连接了”。Client端收到FIN报文后,”就知道可以关闭连接了,但是他还是不相信网络,怕Server端不知道要关闭,所以发送ACK后进入TIME_WAIT状态,如果Server端没有收到ACK则可以重传。“,Server端收到ACK后,”就知道可以断开连接了”。Client端等待了2MSL后依然没有收到回复,则证明Server端已正常关闭,那好,我Client端也可以关闭连接了。Ok,TCP连接就这样关闭了!

  1. client 发送FIN报文
  2. server 接收之后,继续发送data(如果还有的话),返回ACK。
  3. server 没有data 需要继续发送,则继续返回Finish。
  4. client 接收之后,发回ACK。

为什么要三次挥手?
在只有两次“握手”的情形下,假设Client想跟Server建立连接,但是却因为中途连接请求的数据报丢失了,故Client端不得不重新发送一遍;这个时候Server端仅收到一个连接请求,因此可以正常的建立连接。但是,有时候Client端重新发送请求不是因为数据报丢失了,而是有可能数据传输过程因为网络并发量很大在某结点被阻塞了,这种情形下Server端将先后收到2次请求,并持续等待两个Client请求向他发送数据。问题就在这里,Cient端实际上只有一次请求,而Server端却有2个响应,极端的情况可能由于Client端多次重新发送请求数据而导致Server端最后建立了N多个响应在等待,因而造成极大的资源浪费!所以,“三次握手”很有必要!
为什么要四次挥手?
试想一下,假如现在你是客户端你想断开跟Server的所有连接该怎么做?第一步,你自己先停止向Server端发送数据,并等待Server的回复。但事情还没有完,虽然你自身不往Server发送数据了,但是因为你们之前已经建立好平等的连接了,所以此时他也有主动权向你发送数据;故Server端还得终止主动向你发送数据,并等待你的确认。其实,说白了就是保证双方的一个合约的完整执行
是否服务器端可以主动断开连接?
最好是超时断开,防止客户端死机,这样能节省系统资源。
不过需要客户端实现重连机制
什么时候会出现长时间的close_wait状态?
Server在被动关闭连接情况下,在已经接收到FIN,但是还没有发送自己的FIN的时刻,连接处于CLOSE_WAIT状态。
通常来讲,CLOSE_WAIT状态的持续时间应该很短,正如SYN_RCVD状态。但是在一些特殊情况下,就会出现连接长时间处于CLOSE_WAIT状态的情况。close_wait状态过多,会造成系统产生大量“Too many open files” 。在服务器与客户端通信过程中,因服务器发生了socket未关导致的closed_wait发生,致使监听port打开的句柄数到了1024个,且均处于close_wait的状态,最终造成配置的port被占满出现“Too many open files”,无法再进行通信。
比如:Server接受到FIN,但是忙于读或者写(read引起阻塞),没有关闭socket。(被动关闭方未关闭socket造成)
解决方法:
基本的思想就是要检测出对方已经关闭的socket,然后关闭它。
1、代码需要判断socket,一旦read返回0,断开连接,read返回负,检查一下errno,如果不是AGAIN,也断开连接。(注:在UNP 7.5节的图7.6中,可以看到使用select能够检测出对方发送了FIN,再根据这条规则就可以处理CLOSE_WAIT的连接)
2、给每一个socket设置一个时间戳last_update,每接收或者是发送成功数据,就用当前时间更新这个时间戳。定期检查所有的时间戳,如果时间戳与当前时间差值超过一定的阈值,就关闭这个socket。
3、使用一个Heart-Beat线程,定期向socket发送指定格式的心跳数据包,如果接收到对方的RST报文,说明对方已经关闭了socket,那么我们也关闭这个socket。
4、设置SO_KEEPALIVE选项,并修改内核参数
前提:启用socket的KEEPALIVE机制://启用socket连接的KEEPALIVEint iKeepAlive = 1;setsockopt(s, SOL_SOCKET, SO_KEEPALIVE, (void )&iKeepAlive, sizeof(iKeepAlive));
缺点:影响所有打开KEEPALIVE选项的socket;
8. TCP如何判断连接断开(socket是否断开,正常或者异常)?
非阻塞的select方式进行socket检查。
如果网络中间有多级路由,路由当掉等很多情况出现,所以建议程序中在应用层中加入心跳(heartbeat机制)和重连来维持连接的状态。
最近在做一个服务器端程序,C/S结构。功能方面比较简单就是client端与server端建立连接,然后发送消息给server。我在server端会使用专门的线程处理一条socket连接。这就涉及到一个问题,如果socket连接断开(异常,正常)后,我如何才能感知到?server端这边是绝对被动的,sever端不能主动断开连接。也没有连接链路维持包之类的。client端发送数据的时间也是不定的。在socket连接断开后,server要能够感知到并释放资源。
方法1:select +非阻塞IO
当使用 select()函数测试一个socket是否可读时,如果select()函数返回值为1,且使用recv()函数读取的数据长度为0 时,就说明该socket已经断开。
为了更好的判定socket是否断开,我判断当recv()返回值小于等于0时,socket连接断开。但是还需要判断 errno是否等于 EINTR 。如果errno == EINTR 则说明recv函数是由于程序接收到信号后返回的,socket连接还是正常的,不应close掉socket连接。
PS:对于*堵塞socket的recv函数
会在以下三种情况下返回:
(1)recv到数据时,会返回。
(2)在整个程序接收到信号时,返回-1。errno = EINTR。//在程序的起始阶段,屏蔽掉信号的除外。部分信号还是屏蔽不掉的。
(3)socket出现问题时,返回-1.具体错误码看 man recv()
(4)一定要看 man 说明,很详细,很有帮助。