本章主要介绍socket编程相关的函数,代码实现参考下面几篇博文:
C/C++语言实现udp客户端
C/C++语言实现tcp客户端和tcp服务端,Qt调用测试
C/C++实现http发起GET/POST请求

tcp和udp通信流程图

tcp通信流程:
tcp通信流程
udp通信流程:
udp通信流程

socket函数

函数原型

#include<sys/types.h>
#include<sys/socket.h>
int socket(int domain, int type, int protocol);

建立一个协议族为domain、协议类型为type、协议编号为protocol的套接字文件描述符。
成功,返回一个标识这个套接字的文件描述符,失败的时候返回-1,使用errno获得错误信息。
domain
domain用于设置网络通信的域,函数socket()根据这个参数选择通信协议的族。通信协议族在文件sys/socket.h中定义。
在这里插入图片描述
type
type用于设置套接字通信的类型,主要有SOCKET_STREAM(流式套接字)、SOCK——DGRAM(数据包套接字)等。
在这里插入图片描述
protocol
protocol用于制定某个协议的特定类型,即type类型中的某个类型。通常某协议中只有一种特定类型,这样protocol参数仅能设置为0;但是有些协议有多种特定的类型,就需要设置这个参数来选择特定的类型。

1、类型为SOCK_STREAM的套接字表示一个双向的字节流,与管道类似。流式的套接字在进行数据收发之前必须已经连接,连接使用connect()函数进行。一旦连接,可以使用read()或者write()函数进行数据的传输。流式通信方式保证数据不会丢失或者重复接收,当数据在一段时间内任然没有接受完毕,可以将这个连接人为已经死掉。
2、SOCK_DGRAM和SOCK_RAW 这个两种套接字可以使用函数sendto()来发送数据,使用recvfrom()函数接受数据,recvfrom()接受来自制定IP地址的发送方的数据。
3、SOCK_PACKET是一种专用的数据包,它直接从设备驱动接受数据。

一般的网络系统提供了三种不同类型的套接字,以供用户在设计网络应用程序时根据不同的要求来选择。这三种套接为流式套接字(SOCK-STREAM)、数据报套接字(SOCK-DGRAM)和原始套接字(SOCK-RAW)。
(1)流式套接字。它提供了一种可靠的、面向连接的双向数据传输服务,实现了数据无差错、无重复的发送。流式套接字内设流量控制,被传输的数据看作是无记录边界的字节流。在TCP/IP协议簇中,使用TCP协议来实现字节流的传输,当用户想要发送大批量的数据或者对数据传输有较高的要求时,可以使用流式套接字。
(2)数据报套接字。它提供了一种无连接、不可靠的双向数据传输服务。数据报以独立的形式被发送,并且保留了记录边界,不提供可靠性保证。数据在传输过程中可能会丢失或重复,并且不能保证在接收端按发送顺序接收数据。在TCP/IP协议簇中,使用UDP协议来实现数据报套接字。在出现差错的可能性较小或允许部分传输出错的应用场合,可以使用数据报套接字进行数据传输,这样通信的效率较高。
(3)原始套接字。该套接字允许对较低层协议(如IP或ICMP)进行直接访问,常用于网络协议分析,检验新的网络协议实现,也可用于测试新配置或安装的网络设备。

bind函数

函数原型

#include<sys/socket.h>
/**
      * bind,将套接字文件描述符、端口号和ip绑定到一起
      * 参数1,sockfd 表示socket函数创建的通信文件描述符
      * 参数2,sockaddr 表示struct sockaddr的地址,用于设定要绑定的ip和端口
      * 参数3,addrlen 表示所指定的结构体变量的大小
      * 返回值:成功返回0,失败返回-1
      **/
int bind(int sockfd,  const struct sockaddr, socklen_t addrlen);

把一个本地协议地址赋予一个套接字。
1、bind函数可以指定一个端口号,或指定一个IP地址,也可以两者都指定,还可以都不指定。
2、如果指定端口号为0,那么内核就在bind被调用时选择一个临时端口。
3、然而如果指定IP地址为通配地址,那么内核将在套接字已连接或已在套接字上发出数据报时才选择一个本地IP地址。
4、如果一个TCP客户或服务器未曾调用bind绑定一个端口,当调用connect或listen时,内核就要为相应套接字选一个临时端口。
5、进程可以把一个特定的IP地址捆绑到它的套接字上,不过这个IP地址必须属于其所在的网络接口之一。
6、TCP客户端通常不把IP地址捆绑到它的套接字上。当连接套接字时,内核将根据所用的外出网络接口来选择源IP地址,而所用外出接口则取决于到达服务器所需的路径。
7、如果TCP服务器没有吧IP地址绑定到它的套接字上,内核就把客户发送的SYN的目的IP地址作为服务器的源IP地址。

listen函数

listen在套接字函数中表示让一个套接字处于监听到来的连接请求的状态。

#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int listen(int sockfd, int backlog);

sockfd:socket文件描述符;
backlog:指排队等待建立3次握手队列长度;
返回值:成功返回0,失败返回-1。
当一个主动套接字(即没有listen()的套接字)调用listen()变为被动套接字时,系统会为该socket维护两个队列,第一个队列是已经建立连接(即完成三次握手建立连接)的套接字,第二个队列是为建立完整连接的套接字(即正在完成三次握手)。

accept函数

用于从已完成连接队列返回下一个已完成连接(listen函数第一个队列)。如果已完成连接队列为空,进程进入睡眠(假设socket为阻塞模式)。

#include <sys/types.h>   
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

参数sockfd是由socket函数返回的套接字描述符
参数addr和addrlen用来返回已连接的对端进程(客户端)的协议地址。如果我们对客户端的协议地址不感兴趣,可以把arrd和addrlen均置为空指针。
返回值:若成功则为非负描述符,若出错则为-1

如果accept成功,那么其返回值是由内核自动生成的一个全新描述符,代表与客户端的TCP连接。一个服务器通常仅仅创建一个监听套接字,它在该服务器生命周期内一直存在。内核为每个由服务器进程接受的客户端连接创建一个已连接套接字。

connect函数

#include<sys/socket.h>
#include<sys/types.h>
int connect(int sockfd, const struct sockaddr* server_addr, socklen_t addrlen)

sockfd:socket文件描述符;
server_addr:入参,目标地址指针,目标地址中必须包含IP和端口信息;
addrlen:入参,传入sizeof(server_addr)大小。
返回值:0成功,-1失败。
客户端需要调用connect()连接服务器,connect和bind的参数形式一致,区别在于bind的参数是自己的地址,而connect的参数是对方的地址。
connect函数的功能是完成一个有连接协议的连接过程,对tcp来说就是三次握手过程。
阻塞模式的connect
connect默认是阻塞的,当发出一个不能立即完成的套接字调用时,其进程阻塞等待响应操作完成,连接超时时间由linux内核参数决定,可能是几十秒,甚至超过1分钟。
非阻塞模式的connect
在高并发socket编程中我们经常会使用到异步socket也就是非阻塞socket,防止主线程被阻塞。
非阻塞模式调用connect会立即返回,如果返回0则表示连接成功;返回错误,errno等于EINPROGRESS,这种情况表示socket正在连接,errno等于其他则表明连接失败。
通常的策略是使用epoll监听这个socket,连接失败会触发EPOLLERR事件,socket连接成功一般触发EPOLLOUT事件。

recv、recvfrom、read函数

recv
如果s的接收缓冲区中没有数据或者协议正在接收数据,那么recv就一直等待,直到协议把数据接收完毕;
当协议把数据接收完毕,recv函数就把s的接收缓冲区中的数据copy到buf中。(注意协议接收到的数据可能大于buf的长度,所以 在这种情况下要调用几次recv函数才能把s的接收缓冲中的数据copy完。recv函数仅仅是copy数据,真正的接收数据是协议来完成的), recv函数返回其实际copy的字节数。如果recv在copy时出错,那么它返回SOCKET_ERROR。

int recv( SOCKET s, char *buf, int len, int flags);

参数s:指定接收端套接字描述符;
参数buf:指明一个缓冲区,该缓冲区用来存放recv函数接收到的数据;
参数len:指明buf的长度;
参数flags:一般置0。
返回值:成功返回接收到的字节数。另一端已关闭返回0。失败返回-1,设置errno:

EAGAIN:套接字已标记为非阻塞,而接收操作被阻塞或者接收超时
EBADF:sock不是有效的描述词
ECONNREFUSE:远程主机阻绝网络连接
EFAULT:内存空间访问出错
EINTR:操作被信号中断
EINVAL:参数无效
ENOMEM:内存不足
ENOTCONN:与面向连接关联的套接字尚未被连接上
ENOTSOCK:sock索引的不是套接字 当返回值是0时,为正常关闭连接;

如果是EINTR 、 EWOULDBLOCK、 EAGAIN错误,认为连接是正常的,继续接收。

EWOULDBLOCK:用于非阻塞模式,不需要重新读或者写
EINTR:指操作被中断唤醒,需要重新读/写
EAGAIN:对非阻塞socket而言,EAGAIN不是一种错误。这个错误不会破坏socket的同步,不用管它,下次循环接着recv就可以。

recvfrom
通常和sendto配对使用。
recvfrom()从(已连接)套接口上接收数据,并捕获数据发送源的地址。对于SOCK_STREAM类型的套接口,最多可接收缓冲区大小个数据。对于数据报类套接口,队列中第一个数据报中的数据被解包,但最多不超过缓冲区的大小。

int recvfrom(SOCKET s,void *buf,int len,unsigned int flags, struct sockaddr *from,int *fromlen);

参数:
s: 标识一个已连接套接口的描述字。
buf: 接收数据缓冲区。
len: 缓冲区长度。
flags: 调用操作方式。
from: (可选)指针,指向装有源地址的缓冲区。
fromlen:(可选)指针,指向from缓冲区长度值。
read
通常和write配对使用。

#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);

成功返回读取的字节数,出错返回-1并设置errno,如果在调read之前已到达文件末尾,则这次read返回0。

send、write、sendto、sendmsg函数

send和write

#include <sys/types.h>
#include <sys/socket.h>
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
ssize_t write(int sockfd, const void *buf, size_t len);

参数sockfd:是客户端对应的套接字(千万注意)
参数buf:指向发送的信息所在的缓冲区(内存)
参数len:是缓冲区的大小
参数flags:一般设置为0
返回值:成功返回发送的字节数,失败返回对应的失败码。
send和write函数只能在套接字处于连接状态的时候才能使用。send和write的唯一区别就是最后一个参数:flags的存在,当我们设置flags为0时,send和wirte是同等的。
当消息不适合套接字的发送缓冲区时,send通常会阻塞,除非套接字在事先设置为非阻塞的模式,那样他不会阻塞,而是返回EAGAIN或者EWOULDBLOCK错误,此时可以调用select/epoll函数来监视何时可以发送数据。

sendto

#include <sys/types.h>
#include <sys/socket.h>
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
               const struct sockaddr *dest_addr, socklen_t addrlen);

dest_addr:发送目标地址;
addrlen:sizeof(dest_addr)地址长度。
sendto函数如果在连接状态(SOCK_STREAM, SOCK_SEQPACKET)下使用,后面的两个参数dest_addr和addrlen被忽略的,如果不把他们设置成null和0,会返回错误EISCONN,如果把他们设置成NULL和0,发现实际上不是连接状态,会返回错误EISCONN。正常连接下,它和send同等。
sendmsg

#include <sys/types.h>
#include <sys/socket.h>
ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags);
struct msghdr {
               void         *msg_name;       /* optional address */
               socklen_t     msg_namelen;    /* size of address */
               struct iovec *msg_iov;        /* scatter/gather array */
               size_t        msg_iovlen;     /* # elements in msg_iov */
               void         *msg_control;    /* ancillary data, see below */
               size_t        msg_controllen; /* ancillary data buffer len */
               int           msg_flags;      /* flags on received message */
           };

对于sendmsg,目标地址是由msg的成员 msg.msg_name,来给出, msg.msg_namelen 指定大小。send和sendto发送消息的是buf,而且有它的指定长度len,但是在sendmsg中是由msg.msg_iov数组来决定的。所以sendmsg允许发送辅助信息(也称为控制信息)。

如果发送的消息太长,不能以原子形式通过底层协议,则返回EMSGSIZE。并且不发送这个消息。

close、shutdown函数

int close(int sockfd);
int shutdown(int sockfd,int how)

关闭的文件描述符,返回值:成功返回0,出错返回-1并设置errno。
某个进程调用了close(sockfd)函数,描述符的计数就会减1,直到计数为0。当计数为0时,也就是所用进程都调用了close,这时程序会调用shutdown函数释放套接字。
有些地方见到调用了shutdown()之后接着就是close()。有种解释是:调用shutdown()只是进行了TCP断开, 并没有释放文件描述符,需要调用close()来释放。
某个进程中调用了shutdown(sockfd,SHUT_RDWR)函数,其它的进程将无法通过sockfd进行通信。而如果调用的不是shutdown而是close(sockfd),将不会影响到其它进程。

shutdown的how参数:

1.SHUT_RD:值为0,关闭连接的读这一半,套接字中不再有数据接收,且套接字接收缓冲区中的现有数据全都被丢弃,该套接字描述符不能再被进程调用,对端发送的数据会被确认,然后丢弃。
2.SHUT_WR:值为1,关闭连接的写这一半。这称为半关闭,当前在套接字发送缓冲区数据被发送,然后连接终止序列。不论套接字描述符引用技术是否等于0,写半部都会被关闭。
3.SHUT_RDWR:值为2,连接的读和写都关闭。相当于先调用SHUT_RD,再调用SHUT_WR。

htonl、ntohl、htons、ntohs本地主机和网络字节序转换

数据在计算机里面存储方式分为小端模式和大端模式两种,网络中不同机器采用的大小端模式不一定相同,称某个系统采用的字节序为主机字节序,而网络协议中端口号和网络地址均是以网络字节序传输的,其是大端存储模式,如果主机字节序采用小端存储,在网络通信时,就必须用函数进行转换。

网络字节顺序NBO(Network Byte Order)
主机字节顺序(HBO,Host Byte Order)

先理清楚小端大端字节序在内存中怎么存储的:
大端模式,是指数据的高字节保存在内存的低地址中,而数据的低字节保存在内存的高地址中,这样的存储模式有点儿类似于把数据当作字符串顺序处理:地址由小向大增加,数据从高位往低位放;这和我们的阅读习惯一致。

小端模式,是指数据的高字节保存在内存的高地址中,而数据的低字节保存在内存的低地址中,这种存储模式将地址的高低和数据位权有效地结合起来,高地址部分权值高,低地址部分权值低。

以unsigned int value = 0x12345678为例,用unsigned char buf[4]来表示value。
小端模式:buf[0] (0x78) – 低位字节,buf[1] (0x56),buf[2] (0x34),buf[3] (0x12) – 高位字节。
大端模式:buf[0] (0x12) – 高位字节,buf[1] (0x34),buf[2] (0x56),buf[3] (0x78) – 低位字节。
可以看到,存储在内存后,大端模式更符合我们从左至右的阅读习惯。

我们使用的大部分电脑主机都是小端模式,因此要做字节序转换,下面几个函数是做端口的字节序转换。

htonl();	//“Host to Network Long int”,32位转换
ntohl();	//“Network to Host Long int”,32位转换
htons();	//“Host to Network Short int”,16位转换
ntohs();	//“Network to Host Short int”,16位转换

下面用示例验证这几个函数,看看大端小端字节在内存中是怎么存储的。

    int32_t listenPort = 50068;
    struct sockaddr_in local_addr;
    local_addr.sin_family = AF_INET;
    //1.使用htons转为网络字节序
    local_addr.sin_port = htons(listenPort);
    local_addr.sin_addr.s_addr = INADDR_ANY;

    printf("local_addr.sin_port =%d\n",local_addr.sin_port);
    printf("sizeof(int32_t)=%lu\n",sizeof(int32_t));
    printf("sizeof(local_addr.sin_port)=%lu\n",sizeof(local_addr.sin_port));

    int32_t tmpPort = ntohs(local_addr.sin_port);//2.ntohs转回主机字节序
    printf("tmpPort =%d\n",tmpPort);

    __uint32_t sin_port32 = htonl(listenPort);//3.使用htonl转成32位网络字节序
    printf("sin_port32 =%d\n",sin_port32);
    in_port_t sin_port16 = htonl(listenPort);//4.使用htonl转成16位网络字节序
    printf("sin_port16 =%d\n",sin_port16);

1.使用htons转为网络字节序
listenPort端口号是50068,数据类型int32_t 是32位(4字节),转换成16进制是0x0000c394,主机采用小端字节序,低字节存储在低位地址,比如0x94是最低位地址,存储时存在地址的第1位,0xc3是倒数第二低位,存储在第2位,以此类推,内存中存储的是0x94c30000,这和我们的阅读习惯是相反的;

转为网络字节序后赋值给local_addr.sin_port,注意这个是uint16_t数据类型,16位(2字节),只取上面50068的前两个内存字节进行转换,转换完成后内存中存储的是0xc394,根据上面介绍的规则,转成数据就是0x94c3,十进制是38083;

2.ntohs转回主机字节序
返回的tmpPort 是4字节,ntohs(local_addr.sin_port)参数是2字节,先做2字节转换,再在2字节内存后面补2字节的0。

3.使用htonl转成32位网络字节序
listenPort是4字节,转换后的sin_port32 也是4字节,转换完成的sin_port32 内存中是0x0000c394,转成数据是0x94c30000,十进制是2495807488。

4.使用htonl转成16位网络字节序
htonl是32位转换,但只取前16位,本例得到的是0x0000,就是0,这种用法是错误的。

上面demo打印:

local_addr.sin_port =38083
sizeof(int32_t)=4
sizeof(local_addr.sin_port)=2
tmpPort =50068
sin_port32 =2495807488
sin_port16 =0

在这里插入图片描述

inet_addr、inet_aton、inet_ntop,IP地址转换函数

在TCP/IP网络上使用的是以“.”隔开的十进制数表示IP,但在套接字数据结构中使用的是32位的网络字节序的二进制数值,要用以下函数实现转换,下面介绍开发中常用的几个。

#include <arpa/inet.h>
//参数是straddr.分十进制IP,返回值是32位网络字节序
in_addr_t  inet_addr(const char* straddr);
//straddr是入参.分十进制IP,出参addrptr结构体包含32位网络字节序
int inet_aton(const char* straddr,struct in_addr *addrptr);
//将32位网络字节序转换位.分十进制IP地址
const char *inet_ntop(int af, const void *src, char *dst, socklen_t cnt);

IPv4是4字节32位,.分十进制表示法是把每个字节转换位十进制,再用.分隔表示,比如192.168.3.66。
转换位32位网络字节序时,是把这4个字节以网络字节序的顺序在内存排列。

	//1..分十进制IP转换位32位网络字节序
    char serverIp[] = "192.168.3.66";
    in_addr_t n_addr = inet_addr(serverIp);
    printf("n_addr=%u\n",n_addr);
	///2..分十进制IP转换位32位网络字节序,存在结构体里
    struct in_addr addr;
    inet_aton(serverIp, &addr);
    printf("inet_aton ip=%u\n", addr.s_addr);
	///3.32位网络字节序转换位点分十进制IP
    char remoteIp[32]={0};
    inet_ntop(AF_INET,&addr.s_addr, remoteIp, sizeof(remoteIp));
    printf("remoteIp=%s\n",remoteIp);

1、点分十进制IP转换位32位网络字节序
serverIp是字符数组,内容是"192.168.3.66",共12个字符,存储到内存时先把字符按照ASCII码表转换为十进制,再转换为16进制,比如字符’1’,ASCII码表转换为十进制是49,转换为16进制是31;以此类推,"192.168.3.66"在内存中存储的是“31 39 32 2e 31 36 38 2e 33 2e 36 36”。
转换成32位网络字节序时,去掉点,只取数值,4字节分别是192、168、3、66,分别转换成16进制存储到内存是“c0 a8 03 42”,如果用十进制打印出来,就是1107536064(内存转换成数据时,按主机小端规则上面有介绍)。

2、点分十进制IP转换位32位网络字节序,存在结构体里

3、32位网络字节序转换位点分十进制IP
常用于在收到socket数据时,从地址解析出对端点分十进制IP地址,内存转换和上面类似。

demo打印

n_addr=1107536064
inet_aton ip=1107536064
remoteIp=192.168.3.66

setsockopt函数和getsockopt函数

setsockopt用于任意类型、任意状态socket设置选项值;getsockopt用于获取一个套接字的选项。

#include <sys/types.h>
#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);

参数
sockfd,标识套接字的描述符fd。
level,支持SOL_SOCKET、IPPROTO_TCP、IPPROTO_IP和IPPROTO_IPV6,应用开发常用的是SOL_SOCKET,其他几种是修改协议参数。
optname,要为其设置值的套接字选项 (,例如,SO_BROADCAST) 。 optname 参数必须是在指定level内定义的套接字选项,或者行为未定义。
optval,根据套接字选项,请求选项值的缓冲区的指针。
optlen,optval 参数指向的缓冲区的大小(以字节为单位)。
返回值,成功返回0,出错返回-1。

setsockopt常用举例:
1.设置调用close()后,仍可继续重用该socket
因为调用close()一般不会立即关闭socket,而经历TIME_WAIT的过程。比如重用一个端口时报错port is already in use,可以这么设置。

BOOL bReuseaddr = TRUE;
setsockopt( s, SOL_SOCKET, SO_REUSEADDR, ( const char* )&bReuseaddr, sizeof( BOOL ) );

2.不进入TIME_WAIT状态
如果要已经处于连接状态的soket在调用close()后强制关闭,不经历TIME_WAIT的过程:

BOOL bDontLinger = FALSE;
setsockopt( s, SOL_SOCKET, SO_DONTLINGER, ( const char* )&bDontLinger, sizeof( BOOL ) );

3.设置阻塞模式超时机制
在send(),recv()过程中有时由于网络状况等原因,收发不能预期进行,可以设置收发时限:
在阻塞模式下设置超时时限是很有必要的,否则程序会阻塞在recv、accept而没机会执行循环中的其他代码,例如while(isruning){accept},如果阻塞了,无法判断isruning改变,导致死循环无法退出。

int nNetTimeout = 1000; //1秒
//发送时限
setsockopt( socket, SOL_SOCKET, SO_SNDTIMEO, ( char * )&nNetTimeout, sizeof( int ) );

//阻塞模式下recv接收超时
struct timeval tv_out;
tv_out.tv_sec = 0;
tv_out.tv_usec = 500*1000;
setsockopt(tcpclient->fd, SOL_SOCKET, SO_RCVTIMEO, &tv_out, sizeof(tv_out));

4.设置socket缓冲区大小
在send()的时候,返回的是实际发送出去的字节(同步)或发送到socket缓冲区的字节(异步);系统默认的状态发送和接收一次为8688字节(约为8.5K);在实际的过程中如果发送或是接收的数据量比较大,可以设置socket缓冲区,避免send(),recv()不断的循环收发:

// 接收缓冲区
int nRecvBufLen = 32 * 1024; //设置为32K
setsockopt( s, SOL_SOCKET, SO_RCVBUF, ( const char* )&nRecvBufLen, sizeof( int ) );
//发送缓冲区
int nSendBufLen = 32*1024; //设置为32K
setsockopt( s, SOL_SOCKET, SO_SNDBUF, ( const char* )&nSendBufLen, sizeof( int ) );

5.发送数据不执行由系统缓冲区到socket缓冲区的拷贝
在发送数据的时,不执行由系统缓冲区到socket缓冲区的拷贝,以提高程序的性能:

int nZero = 0;
setsockopt( socket, SOL_SOCKET, SO_SNDBUF, ( char * )&nZero, sizeof( nZero ) );

6.接收数据不执行由系统缓冲区到socket缓冲区的拷贝
在接收数据时,不执行将socket缓冲区的内容拷贝到系统缓冲区:

int nZero = 0;
setsockopt( s, SOL_SOCKET, SO_RCVBUF, ( char * )&nZero, sizeof( int ) );

7.UDP广播数据
一般在发送UDP数据报的时候,希望该socket发送的数据具有广播特性:

BOOL bBroadcast = TRUE;
setsockopt( s, SOL_SOCKET, SO_BROADCAST, ( const char* )&bBroadcast, sizeof( BOOL ) );

8.非阻塞模式设置connect延时
在client连接服务器过程中,如果处于非阻塞模式下的socket在connect()的过程中可以设置connect()延时,直到accpet()被调用(此设置只有在非阻塞的过程中有显著的作用,在阻塞的函数调用中作用不大)

BOOL bConditionalAccept = TRUE;
setsockopt( s, SOL_SOCKET, SO_CONDITIONAL_ACCEPT, ( const char* )&bConditionalAccept, sizeof( BOOL ) );

9.等数据发送完成再关闭
如果在发送数据的过程中send()没有完成,还有数据没发送,而调用了close(),以前一般采取的措施是shutdown(s,SD_BOTH),但是数据将会丢失。
某些具体程序要求待未发送完的数据发送出去后再关闭socket,可通过设置让程序满足要求:

struct linger {
u_short l_onoff;
u_short l_linger;
};
struct linger m_sLinger;
m_sLinger.l_onoff = 1; //在调用close(socket)时还有数据未发送完,允许等待
// 若m_sLinger.l_onoff=0;则调用closesocket()后强制关闭
m_sLinger.l_linger = 5; //设置等待时间为5秒
setsockopt( s, SOL_SOCKET, SO_LINGER, (const char*)&m_sLinger, sizeof(struct linger));

10.设置socket connect超时时间
SYN重传次数影响connect超时时间,当重传次数为6时,超时时间为1+2+4+8+16+32+64=127秒。

#include <netinet/tcp.h>
int syncnt = 1;
setsockopt(tcpClient->fd, IPPROTO_TCP, TCP_SYNCNT, &syncnt, sizeof(syncnt));

11.设置SO_KEEPALIVE和TCP心跳保活机制

1、 默认情况下,如果TCP没有数据收发,客户端和服务端就没有数据往来,如果网络断了(比如拔网线),客户端和服务端都无法感知,实测拔网线过一个晚上,客户端和服务端还都是连接态。
2、如果自定义了周期心跳,可以检测网络断连。
3、如果没有自定义周期心跳,也可以通过内核TCP协议栈的保活机制,在没有数据收发一定时间后,自动发送保活探测。

下面介绍内核TCP保活机制,如下参数配置,客户端在没有数据收发60秒后,会发送探测报文,如果没有收到响应,间隔3秒重新发送,最多发送2次探测,还是没有收到响应,则认为连接断开,客户端会关闭连接。

代码实现时,客户端传参socket返回的fd;服务端传参accept返回的fd。

#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/tcp.h>
 
int keepAlive = 1;          // 开启keepalive属性(默认超时时间:/proc/sys/net/ipv4/tcp_keepalive_time 7200 即2小时)
int keepIdle = 60;          // 开始首次KeepAlive探测前的TCP空闭时间,单位秒
int keepInterval = 3;       // 两次KeepAlive探测间的时间间隔
int keepCount = 2;          // 探测尝试的次数.如果第1次探测包就收到响应了,则后几次的不再发.
setsockopt(client_fd, SOL_SOCKET, SO_KEEPALIVE, (void*)&keepAlive, sizeof(keepAlive));
setsockopt(client_fd, SOL_TCP, TCP_KEEPIDLE, (void *)&keepIdle, sizeof(keepIdle));
setsockopt(client_fd, SOL_TCP,TCP_KEEPINTVL, (void *)&keepInterval, sizeof(keepInterval));
setsockopt(client_fd, SOL_TCP, TCP_KEEPCNT, (void *)&keepCount, sizeof(keepCount));

缺点:
KeepAlive是实现在TCP协议栈(四层),TCP keepalive由操作系统负责探测,即便进程死锁或者阻塞,操作系统也会如常收发TCP keepalive消息,对方无法得知这一异常。此外它检查不到机器断电、网线拔出、防火墙这些断线。
因此, 应用层有必要自己来定义心跳包,像WebSocket协议就专门设计了ping/pong的控制包,通常是服务端向客户端发送心跳。

fcntl函数

fcntl系统调用可以用来对已打开的文件描述符进行各种控制操作以改变已打开文件的的各种属性
函数原型:

 #include<unistd.h>
#include<fcntl.h>
int fcntl(int fd, int cmd);
int fcntl(int fd, int cmd, long arg);
int fcntl(int fd, int cmd ,struct flock* lock);

socket常用的功能是设置socket的阻塞方式。

//设置非阻塞,在读取不到数据或是写入缓冲区已满会马上return,而不会阻塞等待
fcntl(_fd, F_SETFL, O_NONBLOCK);

在这里插入图片描述

getpeername函数与getsockname函数

函数定义如下:

#include<sys/socket.h>
//用于获取与某个套接字关联的本地协议地址 
int getsockname(int sockfd, struct sockaddr *localaddr, socklen_t *addrlen);
//用于获取与某个套接字关联的外地协议地址
int getpeername(int sockfd, struct sockaddr *peeraddr, socklen_t *addrlen);

调用成功,则返回0,如果调用出错,则返回-1
本例创建tcp客户端,连接到tcp服务器,把tcp客户端的fd传参到上面两个函数。

struct sockaddr_in localAddr,remoteAddr;
socklen_t  localAddrLen,remoteAddrLen;

localAddrLen = sizeof(localAddr);
//获取本地IP地址和端口
getsockname(pTcpHandle->fd, (struct sockaddr *)&localAddr, &localAddrLen);
printf("local address = %s:%d\n", inet_ntoa(localAddr.sin_addr), ntohs(localAddr.sin_port));

remoteAddrLen = sizeof(remoteAddr);
//获取远端tcp服务器的IP地址和端口
getpeername(pTcpHandle->fd, (struct sockaddr *)&remoteAddr, &remoteAddrLen);
printf("remote address = %s:%d\n", inet_ntoa(remoteAddr.sin_addr), ntohs(remoteAddr.sin_port));

打印

local address = 192.168.3.64:44462
remote address = 192.168.3.66:9090

使用场景:
1、在一个没有调用bind的TCP客户上,connect成功返回后,getsockname用于返回由内核赋予该连接的本地IP地址和本地端口号。
2、在以端口号为0调用bind(告知内核去选择本地临时端口号)后,getsockname用于返回由内核赋予的本地端口号。
3、在一个以通配IP地址调用bind的TCP服务器上,与某个客户的连接一旦建立(accept成功返回),getsockname就可以用于返回由内核赋予该连接的本地IP地址。
4、getpeername只有在连接建立以后才调用,否则不能正确获得对方地址和端口,所以它的参数描述字一般是已连接描述字而非监听套接口描述字。
5、 没有连接的UDP不能调用getpeername,但是可以调用getsockname和TCP一样,它的地址和端口不是在调用socket就指定了,而是在第一次调用sendto函数以后。
6、已经连接的UDP,在调用connect以后,这2个函数(getsockname,getpeername)都是可以用的。但是这时意义不大,因为已经连接(connect)的UDP已经知道对方的地址。

附录1:socket阻塞与非阻塞

阻塞是指当某个函数执行成功的条件当前不满足时,该函数会阻塞当前执行线程,程序执行流在超过时间到达或执行成功的条件满足后恢复继续执行。
非阻塞模式相反,即使某个函数执行成功的条件当前不满足,该函数也不会阻塞当前执行线程,而是立即返回,继续执行程序流。
阻塞与非阻塞模式socket函数一般有connect、accept、send和recv。

send
阻塞模式:send操作将会等待所有数据均被拷贝到发送缓冲区后才会返回。
非阻塞模式:send操作会立即返回,如果发送缓冲区可用大小为0,则会立即返回EWOULDBLOCK错误,表示无法拷贝任何数据到发送缓冲区;如果发送缓冲区可用大小不为0,但小于发送数据的长度,则拷贝可用大小的数据到缓冲区;由此可知,非阻塞send总是尽自己最大能力往发送缓冲区拷贝尽可能多的数据,所以存在非阻塞send返回的大小比发送数据的长度要小的情况。

recv
1、recv返回值大于0,表明接收到数据。
2、recv的返回值为0,那表明连接已经断开,结束接收。
3、recv返回值小于0,如果错误码是EAGAIN或EINTR,可继续接收;其他错误码表示接收失败。
返回小于0时阻塞与非阻塞的区别:
阻塞模式:错误码为EINTR则继续接收,EAGAIN阻塞超时,返回错误。
非阻塞模式:EINTR和EAGAIN都应该继续接收,可自定义超时判断。

EAGAIN
在linux进行非阻塞的socket接收数据时经常出现Resource temporarily unavailable,这表明你在非阻塞模式下调用了阻塞操作,在该操作没有完成就返回这个错误,这个错误不会破坏socket的同步,不用管它,下次循环接着recv就可以。
EINTR
如果出现EINTR即errno为4,错误描述Interrupted system call,也应该继续接收。

connect
参数上面的connect函数介绍。

附录2:socket多进程编程

方法一:设置SO_REUSEPORT选项
使用方法:

fork 后创建listen socket(也可不用fork,手动启动多个进程),多个同用户进程可以bind同一个端口,accept客户端时由内核均衡分配到各个进程,客户端不用管。

原理

内核为处于 LISTEN 状态的 socket 分配了大小为 32 哈希桶。监听的端口号经过哈希算法运算打散到这些哈希桶中,相同哈希的端口采用拉链法解决冲突。当收到客户端的 SYN 握手报文以后,会根据目标端口号的哈希值计算出哈希冲突链表,然后遍历这条哈希链表得到最匹配的得分最高的 Socket。对于使用 SO_REUSEPORT 选项的 socket,可能会有多个 socket 得分最高,这个时候经过随机算法选择一个进行处理。

SO_REUSEPORT 安全

端口劫持:启用 SO_REUSEPORT 选项后,不怀好意的进程也可以监听相同的端口来窃取流量。
内核保护措施:一个linux用户启用 SO_REUSEPORT 选项监听端口,之后只能使用同一个用户监听相同端口,别的用户尝试bind绑定端口会报错( 98 /* Address already in use */)。
也就是说,监听相同端口的进程,必须是同一个linux用户启动的,使用root也无法监听其他用户已经监听的端口。

方法二:bind和listen后fork子进程
使用方法:

主进程 fork 前创建listen socket。fork 出来的子进程共享fd,并accept接受客户端连接,但会出现惊群效应,即多个子进程一起响应连接,但只有一个进程能处理该网络事件,造成了CPU资源浪费和锁竞争。

惊群效应:

1、ngnix 解决惊群:通过加全局锁,子进程抢锁成功则epoll_ctrl添加监听listen socket fd处理连接,抢锁失败的子进程不调用epoll_ctrl添加监听fd,也就不会响应连接事件。但在短时间高并发场景下,可能只有一个子进程accept连接资源,会导致不均衡的问题。
2、默认不解决惊群:如果惊群对系统影响不大,性能消耗能接受,也可以不处理。
3、设置标记位WQ_FLAG_EXCLUSIVE :内核中添加等待队列,队列存放有 WQ_FLAG_EXCLUSIVE 标识的进程,多线程阻塞accept时,系统内核只会唤醒所有正在等待此事件的队列的第一个,队列中的其他进程等待下一次事件发生。

附录3:udp使用connect函数的作用

对于UDP来说,socket函数建立一个插口;bind函数指明了本地IP/端口;connect可以用来指明目的IP/端口。

在设置SO_REUSEADDR属性的情况下,创建不同的 UDP socket,可以重复bind同一个端口和IP,默认最后绑定的fd接收来自该IP和端口的数据。
如果 UDP socket 使用系统调用 connect 函数,则是在内核把fd和远端IP/port绑定,指明了远端IP/端口,发送接收可以使用send,write操作,getsockname也可以获取远端地址。
可以重复调用 connect ,作用是解绑原来的fd和远端IP/port,并重新绑定fd和远端IP/port。

udp使用 connect 的好处:
1、如果两个 UDP socket bind了同一个IP和端口,再调用 connect 函数把两个 UDP socket 绑定到不同的远端IP/port,这样两个 UDP socket 可以和两个不同的远端udp通信,信息不会交叉。
2、内核在处理数据收发时提升效率。
3、高并发服务器中增加系统稳定性。
4、使用write 发送数据失败会返回异步错误,使用sendto则不会检测。

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐