socket

套接字(Socket)编程(三) 套接字可选项

2018-01-13  本文已影响375人  Super超人

套接字具有多种特性,这些特性可通过可选项更改,本篇文章将介绍更改套接字可选项的方法,并以此为基础进一步观察套接字内部

前面介绍了套接字通信的基本函数套接字(Socket)编程(一) 函数概念篇和套接字通信的基本原理接字(Socket)编程(二) 内部通信原理,有需要了解的可以看我前面的两篇文章。

我们之前写程序都是在创建好套接字之后(未经特殊操作)直接使用的,此时通过默认的套接字特性进行同性。之前的示例都较为简单,无需特别操作套接字特性,但有时的确需要更改。

一、可设置套接字的多种可选项

套接字可选项是分层的,不同的协议成可设置的套接字可选项是不一样的

协议层 功能
SOL_SOCKET 套接字相关通用可选项的设置
IPPROTO_IP 在IP层设置套接字的相关属性
IPPROTO_TCP 在TCP层设置套接字相关属性

下面我们列出套接字三个协议层部分可选项,并对其中常用的做下介绍和使用示例

SOL_SOCKET 选项名 说明 数据类型
SO_DEBUG 打开或关闭调试信息 int
SO_BROADCAST 允许或禁止发送广播数据 int
SO_DONTROUTE 打开或关闭路由查找功能 int
SO_ERROR 获得套接字错误 int
SO_KEEPALIVE 开启套接字保活机制 int
SO_REUSEADDR 是否启用地址再分配,主要原理是操作关闭套接字的Time-wait时间等待的开启和关闭 int
SO_LINGER 是否开启延时关闭,开启的情况下调用 close() 函数会被阻塞,同时可以设置延迟关闭的超时时间,如果到超时未发送完数据则直接复位套接口的虚电路(属于异常关闭),如果超时时间内发送完数据则正常关闭套接字回调 close()函数 struct linger
SO_TYPE 获得套接字类型(这个只能获取,不能设置) int
SO_RCVBUF 接收缓冲区大小 int
SO_SNDBUF 发送缓冲区大小 int
SO_RCVLOWAT 接收缓冲区下限 int
SO_SNDLOWAT 发送缓冲区下限 int
SO_RCVTIMEO 接收超时 struct timeval
SO_SNDTIMEO 发送超时 struct timeval
IPPROTO_IP 选项名 说明 数据类型
IP_MULTICAST_TTL 生存时间(Time To Live),组播传送距离 int
IP_ADD_MEMBERSHIP 加入组播 int
IP_DROP_MEMBERSHIP 离开组播 int
IP_MULTICAST_IF 取默认接口或默认设置 int
IP_MULTICAST_LOOP 禁止组播数据回送 int
IP_HDRINCL 在数据包中包含IP首部 int
IP_OPTINOS IP首部选项 int
IPPROTO_TCP 选项名 说明 数据类型
TCP_KEEPIDLE TCP_KEEPALIVE TCP保活机制开启下,设置保活包空闲发送时间间隔 int
TCP_KEEPINTVL TCP保活机制开启下,设置保活包无响应情况下重发时间间隔 int
TCP_KEEPCNT TCP保活机制开启下,设置保活包无响应情况下重复发送次数 int
TCP_MAXSEG TCP最大数据段的大小 int
TCP_NODELAY 不使用Nagle算法 int

二、getsockopt & setsockopt

我们几乎可以对照上面所有可选项进行读取和设置(当然有些套接字只能进行其中的一中操作),可选项的读取和设置通过下面两个函数完成

#include <sys/socket.h>

int getsockopt(int sock,          //用于查看选项套接字描述符
               int level,         //要查看可选项的协议层
               int optname,       //要查看可选项名字
               void *optval,      //保存查看结果的缓冲地址值
               socklen_t *optlen  //指向第四个参数optval传递的缓冲大小的指针
               )
//成功时返回0,失败返回-1
#include <sys/socket.h>

int setsockopt(int sock,          //用于更改可选项套接字描述符
               int level,         //要更改可选项的协议层
               int optname,       //要更改可选项名字
               void *optval,      //要更改可选项的值
               socklen_t optlen   //第四个参数optval传递的可选项信息的字节数
               )
//成功时返回0,失败返回-1

三、应用举例

下面来介绍有关可选项的设置读取和示例,我将从我认为重要的往下列举

1. SO_KEEPALIVE

SO_KEEPALIVE 是否开启TCP的保活机制
平时开发中,常见的对于面向连接的TCP连接,断开分两种情况
①、连接正常关闭,调用 close() 会经历断开的四次握手,send()recv()立马返回错误;
②、连接的对端异常关闭,比如网络断掉或突然断电,这种断开对方是检测不到连接出现异常的

解决这种异常断开的检测机制一般有两种:
①、自己在应用层定时发送心跳包来判断连接是否正常,此方法比较通用,灵活可控,但改变了现有的协议;
②、使用TCP的keepalive机制,TCP协议自带的保活功能,使用起来简单,减少了应用层代码的复杂度, 推测也会更节省流量,因为一般来说应用层的数据传输到协议层时都会被加上额外的包头包尾,由TCP协议提供的检活,其发的探测包,理论上实现的会更精妙(用更少的字节完成更多的目标),耗费更少的流量;

keepalive原理:TCP内嵌有心跳包,以服务端为例,当server检测到超过一定时间(tcp_keepalive_time 7200s 即2小时)没有数据传输,那么会向client端发送一个keepalive packet,此时client端有三种反应:

我们可以根据自己的需求来修改这三个参数的系统默认值,下面我们来通过一个简单的回响服务器代码来实现保活机制的逻辑实现

#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <errno.h>

//这个头文件里面包含设置TCP协议层保活三个参数的可选项
#include <netinet/tcp.h>

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        
        int serv_sockfd, clnt_sockfd;
        struct sockaddr_in serv_addr,clnt_addr;
        
        serv_addr.sin_len = sizeof(struct sockaddr_in);
        serv_addr.sin_family = AF_INET;
        serv_addr.sin_port = htons(2000);
        serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
        
        serv_sockfd = socket(AF_INET, SOCK_STREAM, 0);
        if (bind(serv_sockfd, (struct sockaddr*)&serv_addr, serv_addr.sin_len) < 0) return 0;
        if (listen(serv_sockfd, 1) < 0) return 0;
        
        socklen_t len_t = sizeof(struct sockaddr_in);
        clnt_addr.sin_len = len_t;
        clnt_sockfd = accept(serv_sockfd, (struct sockaddr*)&clnt_addr, &len_t);
        if (clnt_sockfd < 0) return 0;
        close(serv_sockfd);
        printf("有客户端连接 IP:%s Port:%d\n",inet_ntoa(clnt_addr.sin_addr),ntohs(clnt_addr.sin_port));
        
        //1. 开启keepalive机制
        int keepAlive = 1; // 开启keepalive属性. 缺省值: 0(关闭)
        if (setsockopt(clnt_sockfd, SOL_SOCKET, SO_KEEPALIVE, &keepAlive, sizeof(keepAlive)) == -1)
            printf("开启keepalive机制失败 errorCode:%d descreption:%s\n",errno,strerror(errno));
        
        //2. 设置保活包检测发送时间间隔(TCP_KEEPIDLE 好像被替换为 TCP_KEEPALIVE)
        int keepIdle = 10; // 如果在60秒内没有任何数据交互,则进行探测. 缺省值:7200(s)
        if (setsockopt(clnt_sockfd, IPPROTO_TCP, TCP_KEEPALIVE, &keepIdle, sizeof(keepIdle)) == -1)
            printf("设置保活包检测发送时间间隔 errorCode:%d descreption:%s\n",errno,strerror(errno));
        
        //3. 设置保活包发送无响应重发时间间隔
        int keepInterval = 5; // 探测时发探测包的时间间隔为5秒. 缺省值:75(s)
        if (setsockopt(clnt_sockfd, IPPROTO_TCP, TCP_KEEPINTVL, &keepInterval, sizeof(keepInterval)) == -1)
            printf("设置保活包发送无响应重发时间间隔 errorCode:%d descreption:%s\n",errno,strerror(errno));
        //4. 设置保活包发送无响应重发次数
        int keepCount = 3; // 探测重试的次数. 全部超时则认定连接失效..缺省值:9(次)
        if (setsockopt(clnt_sockfd, IPPROTO_TCP, TCP_KEEPCNT, &keepCount, sizeof(keepCount)) == -1)
            printf("设置保活包发送无响应重发次数 errorCode:%d descreption:%s\n",errno,strerror(errno));
        
        char recv_buffer[512];
        while (1) {
            memset(recv_buffer, 0, 512);
            ssize_t recv_len = recv(clnt_sockfd, recv_buffer, sizeof(recv_buffer), 0);
            if (recv_len == 0) {
                printf("收到数据包长度为0,可能是EOF包 errorCode:%d descreption:%s\n",errno,strerror(errno));
                break;
            }else if (recv_len < 0) {
                printf("读取数据出错 errorCode:%d descreption:%s\n",errno,strerror(errno));
                break;
            }else {
                printf("recv: %s\n",recv_buffer);
                ssize_t sendLen = send(clnt_sockfd, recv_buffer, recv_len, 0);
                if (sendLen < 0) {
                    printf("发送失败 errorCode:%d description:%s\n",errno,strerror(errno));
                }else {
                    printf("send: %s\n",recv_buffer);
                }
            }
        }
        printf("服务器关闭\n");
        close(clnt_sockfd);
    }
    return 0;
}

对于运行结果的总结:
上面示例设置
TCP_KEEPALIVE :设置保活包空闲发送时间间隔为 10s
TCP_KEEPINTVL:设置保活包无响应情况下重发时间间隔为 5s
TCP_KEEPCNT:设置保活包无响应情况下重复发送次数为 3次
计算出来,如果对方TCP连接异常,服务器这边可以在 10+5*3 = 25 大概25s左右的时间内检测出异常,并在读取数据时抛出errno为60原因为Operation timed out的错误

2. SO_REUSEADDR

SO_REUSEADDR 是否启用地址再分配,设置这个可选项将影响套接字关闭的Time-wait状态,还是比较重要的一个可选项。
在第二篇文章接字(Socket)编程(二) 内部通信原理里面有讲到套接字关闭时的四次握手原理,从图中看出主动关闭的一方在接收到对方的FIN包后有一个ACK确认,然后进入Time-wait状态,当时讲到这个Time-wait等待状态主要的为了确认对方已经收到我们最后一个ACK确认消息,确认收到则关闭,没有或者超时则重发最后的确认包,上篇文章也详细的介绍了为什么有这个等待状态,这里就不做过多的重说了,需要了解的可以点击前面连接查看。
PS:这个Wait-time状态只发生在主动断开连接的一方,被断开一方是没有这个状态的。
作为服务器,通常都是客户端主动断开,这个时候没有Wait-time状态,所以不会发生特别的事情,重启也不成问题,但有的时候由于自己异常需要重启,自己作为断开的一方,系统对于这个Socket断开有Wait-time状态,重新运行起来绑定相同的地址和端口就会出现问题,系统会返回“bind error”的错误,因为这个端口正在被使用,这个时候需要Time-wait时间用完,大概2分钟,再运行服务器就可以绑定该端口了!
但是一般服务器是不能等这个时间的,有用户在那重启一次可能已经造成损失,再停个两分钟损失更大,这个时候就需要设置套接字可选项SO_REUSEADDR值为1,去掉最后的Wait-time时间。

#include <sys/socket.h>

int enableReuseAddr(int sock)
{
    int optval = 1;
    if (setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval)) == -1) {
        return 0;
    }
    return 1;
}
3. SO_LINGER

SO_LINGER 此选项从字面意思上来看就是延迟关闭,我们可以这么理解,在调用关闭套接字的情况下,设置了此参数就可以让系统尽量把缓冲区的数据发送出去(因为有超时时间),但是对于此参数的设置分为四种情况,下面我们来列举下
①. 设置结构体里面参数 l_onoff=0 时,为内核缺失情况(系统默认情况),关闭套接字时系统正常调用 close() 函数
②. 设置结构体里面参数 l_onoff=1 非0时,但是参数 l_linger=0超时时间为0时,在关闭套接字时会向对方发送RST信号给对方(非正常的四次握手关闭),对方会读取到Socket重置的错误(errno = 45 \* Connection reset by peer *\)
③. 设置结构体里面参数 l_onoff=1 非0时,但是参数 l_linger设置的超时时间太小,系统在规定的超时时间内没有将全部的数据发送出去,这个时候情况和上面第二种情况 l_linger=0 时是一样的,直接重置Socket向对方发送RST信号(非正常的四次握手关闭)
④. 设置结构体里面参数 l_onoff=1 非0时,参数 l_linger设置的超时时间足够,在发送完全部数据后系统正常调用 close() 函数正常关闭

#include <sys/socket.h>

int enableLinger(int sock)
{
    struct linger so_linger;
    so_linger.l_onoff = 1;  //开启(非0) 关闭(0)
    so_linger.l_linger = 5; //滞留时间,设置为大于0时才起作用
    if (setsockopt(sock, SOL_SOCKET, SO_LINGER, &so_linger, sizeof(so_linger)) == -1) {
        return 0;
    }
    return 1;
}

套接字关闭(主要针对TCP套接字断开)
可能通过上面的介绍,我们理解起来还是感觉哪不对劲,到底Socket关闭都经历了什么?套接字的几种关闭方式有什么区别?设置超时和不超时有什么区别?貌似直接调用 close() 函数,系统也是将数据全部发送给对方后才关闭....带着这些疑问,我们来断下面的分析

①. cloes函数关闭:我们最常用的关闭套接字方式,说到这种关闭我们再次不得不提下多个进程使用同一个套接字(我看过一本有关网络编程的书,里面提到fork出子进程来使用同一个套接字,章标题叫编写多进程服务器端,书名叫《TCP/IP网络编程》是韩国人写的),当多个进程使用同一个Socket时,其中某一个进程调用 close() 函数只是使套接字的引用计数 -1 ,此时该进程无法使用该Socket进行读写操作,其他使用该Socket的进程可以进行正常通信,当引用计数减到0时,系统才会开始释放该套接字,下面我们来说下该函数关闭的过程。
系统默认(就是在没有设置SO_LINGER可选项的情况下)在调用 close() 时,只是将EOF字段(即FIN报文段)写进套接字缓冲区,写进缓冲区就返回,这就是说调用 close() 函数返回,并不代表已经成功将FIN报文段发送给对方(拿A、B两个主机来举例)。

上面就是调用 cloes() 函数经历的四次握手断开,看文字难理解可以结合我前面的一篇文章里面的套接字断开四次握手图对比着理解。

②. 套接字设置了SO_LINGER可选项下,调用close函数关闭:与第①种情况不同的是,在调用 close() 函数并不是立即返回,超时时间足够的情况下,会等待里面所有的数据发送完以及最后调用 close() 函数加进输出缓冲的FIN报文段也发送给对方之后,才会返回,在此之前阻塞了调用 close() 函数的线程。
这个时候就会有疑问,这个可选项会在什么时候用到呢?设和不设置SO_LINGER可选项系统都会将数据都发送给对方后再释放套接字,只不过一个是写进输出缓冲就返回,一个是等发送成功才返回。我在开发中就遇到了一个必须设置该可选项的情况,因为我是做智能家居开发方面,其中有一种配置单片机联网方式就是连接上单片机的LAN网络,这个时候手机跟单片机在同一个局域网下,就可以跟单片机建立TCP连接并将路由器的WiFi名字和密码发送给对方,单片机收到后解析完会给手机端发送一个数据包,表示自己已经收到联网需要的信息然后重启自己连上该路由器,但有时网络不好这个信息并不一定发给手机端(单片机那边只是调用 send()函数返回后再close() 关闭套接字),这时只能说明数据已经写进单片机的输出缓冲,并不代表已经发送给手机端,然后紧接着重启,这时所有的数据都丢失,导致手机端应用层并不知道设备有没有收到发送的指令,也不知道设备有没有解析出报文连接上路由器,此时如果用SO_LINGER可选项对套接字进行设置就很容易解决这个问题,调用 close() 函数直到发送FIN报文段成功才会返回。

③. shutdown半关闭套接字:
从字面意思上我们很容易理解,半关闭就是要么关闭套接字的输入流要么关闭套接字的输出流,要么两个都关闭。

int shutdown(int sock,  //需要断开套接字的文件描述符
             int howto  //传递断开方式信息
             );
//成功时返回0.失败返回-1
howto的可选值分别有如下三种
#define SHUT_RD     0   断开输入流
#define SHUT_WR     1   断开输出流
#define SHUT_RDWR   2   同时断开I/O流

①. 填写 SHUT_RD 断开输入流,这时输出流还可以接着发送数据,这时调用 recv() 一直返回读取数据长度为0,第一次是原因是“No such file or directory”,后面都是“Operation timed out”
②. 填写 SHUT_WR 断开输出流,这时输入流还可以接着读取数据,套接字对方调用recv() 一直返回读取数据长度为0,第一次可以理解为收到对方的EOF报文,输出原因是“No such file or directory”,后面都是“Operation timed out”,这时自己调用 send()函数会抛出SIGPIPE信号量,内核缺失情况下项目停止运行
③. 填写 SHUT_RDWR 同事断开I/O流,原理上就是先调 SHUT_RD 然后再调一次 SHUT_WR

shutdown() 函数与 close()函数最大的区别就是不管大少个进程用同一个套接字,前者只要其中一个进程调用了半关闭,其他的进程使用该套接字也将进入半关闭状态

4. SO_RCVTIMEO & SO_SNDTIMEO

SO_RCVTIMEO 设置套接字读取数据的超时时间,SO_SNDTIMEO 设置套接字发送数据的超时时间。

#include <sys/socket.h>

int setRecvTimeout(int sock)
{
    //设置套接字读取数据超时时间为5s
    struct timeval timeout;
    timeout.tv_sec = 5;
    timeout.tv_usec = 0;
    if (setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)) == -1) {
        return 0;
    }
    return 1;
}

int setSendTimeout(int sock)
{
    //设置套接字发送数据的超时时间为5s
    struct timeval timeout;
    timeout.tv_sec = 5;
    timeout.tv_usec = 0;
    if (setsockopt(sock, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout)) == -1) {
        return 0;
    }
    return 1;
}

这个超时在平时的开发中用来设置TCP套接字倒是不多,一般UDP套接字用的比较多,我前面的一篇iOS开发-TFTP客户端和服务器的实现文章里面在用UDP套接字发送数据给对方,对方收到之后会用UDP回发一个ACK包,这个时候我们才知道对方收到了我的上一个数据包,接着发送下一个数据包,这个里面就用到了套接字的超时选项设置,在规定的时间内没有收到对方的ACK确认包,则重发!UDP套接字发送数据不用建立连接,所以数据发送出去并不知道对方是否收到,这样一应一答的通信设计用超时可选项进行超时设置是再合适不过!

4. SO_RCVBUF & SO_SNDBUF

SO_RCVBUF 设置或获取套接字输入缓冲区的大小,这个缓冲区是我们在创建套接字时系统为我们创建好的,SO_SNDBUF 设置或获取套接字输出缓冲区的大小

#include <sys/socket.h>

int getRecvBufferSize(int sock)
{
    int recvSize;
    socklen_t len;
    if (getsockopt(sock, SOL_SOCKET, SO_RCVBUF, &recvSize, &len) == -1) {
        return 0;
    }else {
        printf("套接字的输入缓冲大小是: %d\n",recvSize);
    }
    return recvSize;
}

int setSendBufferSize(int sock)
{
    int sendSize;
    socklen_t len;
    if (getsockopt(sock, SOL_SOCKET, SO_SNDBUF, &sendSize, &len) == -1) {
        return 0;
    }else {
        printf("套接字的输出缓冲大小是: %d\n",sendSize);
    }
    return sendSize;
}
我自己写了一个客户端的TCP套接字,打印了一下看,这个应该以系统为准,系统不一样分配的大小也不一样!

套接字的输入缓冲大小是: 408300
套接字的输出缓冲大小是: 146988
5. SO_RCVBUF & SO_SNDBUF

SO_RCVLOWAT 可选项用来设置套接字接收缓冲区下限,对于TCP套接字而言,一般被I/O复用系统用来判断套接字是否可读,当套接字缓冲区中可读数据总数大于我们设置的接收缓冲区下限时,I/O复用系统调用将通知应用程序可以从对应的socket上读取数据(触发 select() 函数 或 Linux独有的 epoll() 函数),调用 recv() 函数才会返回,得到输入缓冲区中的数据。
SO_SNDLOWAT 可选项用来设置套接字发送缓冲区的下限,当TCP套接字发送缓冲区中空闲空间(可以写入数据的空间)大于设置的发送缓冲区下限时,I/O复用系统调用将通知应用程序可以往对应的socket上写入数据,调用 send() 函数才有返会(才能将数据写进输出缓冲中)。
默认情况下,TCP套接字接收缓冲区的下限和TCP套接字发送缓冲区的下限标记均为1字节

#include <sys/socket.h>

int setRcvBufferLowerLimit(int sock)
{
    //设置套接字接收下限为10个字节
    int opval = 10;
    if (setsockopt(sock, SOL_SOCKET, SO_RCVBUF, &opval, sizeof(opval)) == -1) {
        return 0;
    }
    return 1;
}

int setSendBufferLowerLimit(int sock)
{
    //设置套接字发送缓冲区的下限为10个字节
    int opval = 10;
    if (setsockopt(sock, SOL_SOCKET, SO_SNDBUF, &opval, sizeof(opval)) == -1) {
        return 0;
    }
    return 1;
}
6. SO_TYPE

SO_TYPE 获取套接字类型,只能用来获取而不能用来设置

#include <sys/socket.h>

int getSocketType(int sock)
{
    int opval;
    socklen_t opvalLen;
    if (getsockopt(sock, SOL_SOCKET, SO_TYPE, &opval, &opvalLen) == -1) {
        return 0;
    }else {
        if (opval == SOCK_DGRAM) {
            printf("UDP套接字\n");
        }else if (opval == SOCK_STREAM) {
            printf("TCP套接字\n");
        }else {
            printf("套接字类型为: %d\n",opval);
        }
    }
    return opval;
}
7. SO_BROADCAST

SO_BROADCAST 允许或禁止发送广播数据,以及后面有关多播的参数设置(IPPROTO_IP协议层的IP_MULTICAST_TTL设置多播传输距离,IP_ADD_MEMBERSHIP加入多播组,IP_DROP_MEMBERSHIP离开多播组...),都会在我的下篇文章里面做详细的介绍,这里就不做过多累述了。

8. TCP_NODELAY

TCP_NODELAY 是否禁用Nagle算法。
为了防止网络数据包过多而发生网络过载,Nagle算法在1984年诞生了,他应用于TCP层,非常简单,是否使用差异看下图

Nagle算法.png

TCP套接字默认使用Nagle算法进行数据交换,因此最大限度的进行缓冲,只有收到上个包的确认ACK后才会传输下个数据包。
在不使用Nagle算法的情况下发送数据,不需要等待收到上个数据包的ACK确认,接着就开始发送下面的数据,这种情况下会对网络流量产生极大的负面影响,即使只传输1个字节的数据,其头信息都有可能是几十个字节。
因此为了提高网络传输效率,必须使用Nagle算法。

但是对于大文件的网络传输,有时不使用Nagle算法能提高传输速度

int enableNagel(int sock)
{
    //禁用Nagle算法
    int opval = 1;
    if (setsockopt(sock, SOL_SOCKET, TCP_NODELAY, &opval, sizeof(opval)) == -1) {
        return 0;
    }
    return 1;
}

四、结语

对于套接字可选项的总结就到这,开发中常用的都已经列举出来了,有遗漏的日后会补上。

上一篇下一篇

猜你喜欢

热点阅读