记得大一第一次接触linux服务端编程时,当时看的linuxc编程实战中有如下一副示意图
上图虽然简单,但是却包含了我们编写服务器客户端程序时所有的最基本的接口。我们平时所用的第三方网络库例如libevent等,都是将上图中的函数接口进一步封装。
图中的接口使用简单,但是控制起来未必容易,接下来,我会为大家一一介绍各个接口的使用方法,以及调用该接口可能遇到的问题
1.Socket接口
socket接口用来创建一个套接字描述符,函数定义如下
int socket(int domain,int type,int protocol);
//成功返回文件描述符,失败返回-1
参数介绍:
.domain指定使用哪个底层协议,PF_INET(ipv4) PF_INET6(ipv6)
.type指定协议的服务类型,例如TCP服务类型为SOCK_STREAM(流协议),udp为SOCK_UGRAM(数据报协议)
.protocol为对具体协议的控制,一般使用默认控制设为0
2.bind接口
bind接口用来绑定一个由1中返回的socket套接字,所谓绑定是指将某个具体的address(包含IP和port)指定给某个套接字,其接口如下
int bind(int sockfd,const struct sockaddr *my_addr,socklen_t addrlen)
参数介绍:
.socketfd为地址所绑定套接字对应的套接字描述符
.sockaddr为所绑定的具体地址
.addrlen为具体大小
关于bind接口,一般服务器程序会调用这个接口,因为作为服务器你的连接套接字绑定了地址,客户端才知道咋么连你。如果作为客户端则这个接口是非必须的,如果你自己给客户端调用了该接口,则连接socket对应的地址就是你所绑定的地址,如果你没调用该接口,则内核会帮你匿名调用该接口,所以最后所绑定的地址也是不确定的
3.listen接口
使用该接口可以使我们2中所绑定的套接字变为监听socket,接听socket可以说是一种特殊的socket,之所以说他特殊是因为它的可读事件为有新的连接到来,并前获取新连接用accept调用(下面会介绍)其接口如下
int listen(int sockfd,int backlog);
//成功返回0,失败-1
参数说明:
.sockfd,需要设置为监听套接字的套接字描述符
.backlog,监听队列的长度
需要注意的是当listen调用完成之后,内核就为我们维护好了俩个队列,如下图所示
图片来自网络
如上图,内核维护的俩个队列分别为syn队列和accept队列。其中syn队列中保存的是还处于TCP三次握手的连接,当完成三次握手则把连接放入accept中供accept读取,该队列的大小为参数backlog的大小,那么,我们平时应该把backlog设为多大才合适呢?设的太小如果accept队列满,服务端会不会把新来的连接拒绝掉呢?其实一般情况下我们的backlog不必设的太大,一般设为5-8就可以了,以为前面说过syn队列会把已建好的连接给accept队列,当accept队列满时,此连接仍然会暂存在syn队列中,所以新连接并不会被丢弃,那么什么时候会拒绝新连接呢?当syn队列满时,内核才会拒绝新来的连接。关于syn队列的大小上限由内核给出,可在/pro/sys/net/ipv4/tcp_max_syn_backlog中查看我的ubuntu默认为256个。
既然存放已建立好的连接的队列accept和存放半连接(正在三次握手)的队列syn队列都有大小限制,那么我们在编写我们的服务器程序时就因该在监听套接字可读时,尽快的去读取它,如果我们在监听套接字可读时,而由于忙其他事来不及处理可读事件,那么可能会使accept队列堆满,accept堆满之后,新的完成三次握手的连接就会继续堆积在syn队列中,当sys队列也满时,那么新的客户端请求就连不上我们的服务器了,后果很严重,所以我们因该在服务端编程中尽可能的在监听队列可读时,就马上将其读走,最通用的方法是给读监听套接字单开一个线程。关于监听队列可读时的读取方法,我想在补充一点,由于accept可以保存多个可读的连接,所以当我们使用I/O复用可读事件发生时可能我们的一般做法是,调用一次accept接口,然后设置新得的套接字,当高并发情况下(同时有好多个连接)其实其实我们完全可以使用一个循环来accept将accept上的连接都读完
4.accept接口
accept用来接受3中监听套接字的新连接,函数详细接口如下
int accept(int sockfd,struct sockaddr *addr,socklen_t *addrlen);
//成功返回新建的套接字描述符,失败返回-1
参数详解:
.sockfd为监听套接字描述符
.addr保存新来的连接的地址信息
.addrlen保存地址的大小
5.send接口
经过上述步骤我们已经可以获得与客户端建立的socket,那么我们接下来就可以通过send接口来给客户端发送数据了,send的接口如下
ssize_t send(int sockfd,const void *buf,size_t len,int flags);
//成功返回写入的数据长度,失败返回-1
*参数详解:*
.sockfd为send具体写入的套接字描述符
.buf为保存待写入数据的用户态缓冲区
.len为待写入数据的大小
.flags为对数据收发提供的一些额外控制
简单的介绍几个我常用的flags标志,及作用
flags | 具体作用 |
---|---|
MSG_DONTWAIT | 对socket的本次操作将是非阻塞的 |
MSG_MORE | 告诉内核准备好,应用程序还有更多的内容送给内核 |
MSG_OOB | 发送带外数据 |
一个简单的缩略的send函数调用示意图
为了让大家对send调用有个简单的初步概念,我画了个简单的示意图如上
对照上图,当程序执行send调用时首先会把用户态的内容如上图1,2,3块拷贝到内核态的TCP发送缓冲区,然后内核协议栈调用tcp_push将发送缓冲区中的数据送至IP层,之后send调用就会返回,因此,send调用返回并不代表我们的数据已经被接收方收到,它只是被送到IP层,送到IP层内核就会想你表态他会尽量将数据发给接收方。
如果你是初学套接字编程的,那么我建议你暂时对send接口理解到上述地步就可以了,接下来我继续阐述我目前对send调用的进一步理解
图片来自百度
上图是一个比我上面画的更详细点的send函数的调用示意图
此俩个图的一个直观区别就是增加了一块大的蓝色的保存MSS(下文中会有简单介绍)分组的内核态缓冲区(里面的MSS分组由TCP发送队列中的指针指向),TCP是一种流协议(字节与字节没有分界,所以TCP其实真正意义上没有沾包这个概念)而MSS将某段连续的流组成固定大小的分组结构,我们上图中的TCP发送队列中保存的就是每个MSS分组结构的地址,而MSS分组中保存的待发送内容的地址指向蓝色区域的发送缓冲区
上面说了那么多的MSS分组,那么什么是MSS是什么呢?
先用tcpdum抓个我与我的阿里云ssh时的数据包结果如下
这是我的主机发给ip为192.168.20.108发给我阿里云的一个三次握手的第一步由Flags[s],并且没有ack可得。仔细观察其中有个字段为mss 1460,这里所说的mss大小正是我们上述所讲的MSS分组的期望大小,那么告诉我们这么一个大小究竟有什么用呢?
为了说清楚上述问题,我先从MTU说起,以以太网为例,以太网的链路层要求其传输的数据帧(数据封装的最后形态)最大不超过1500,帧是通过封装IP报文段获得的,所以MTU的大小限制就意味着限制了单个IP报文段的大小,但是我们通过查看IP数据报首部的长度字段发现,其为16位,说明IP报文段的最大长度可以达到65535字节,明显这与我们上面强调的链路层的1500大小限制产生了冲突,于是这种超出了MTU大小的IP数据包就会在IP层发生分片,将这种超过MTU限制的IP数据包切成多个符合要求的小数据报,此时就能满足我们以太网链路层MTU的限制了,这些小IP数据报在链路层被封装成一个个数据帧发送给接收方,这些帧通过接收方的链路层,拆分为IP数据报进入IP层以后,会进行IP重组,发送方被拆成的一个个小数据报要组合成之前的那个大数据报才能被接着送往上层协议层TCP层(关于这些知识如果想详细理解请看TCP/IP详解)
很明显接受方等待完重组才能送往TCP层效率有点低,如果重组的IP数据报有一块丢失的话,那么效率更低。那么我们如何规避这种低效率呢?解决办法就是我们让我们传给IP层的TCP数据报别超过(MTU-IP数据报首部(20)-TCP数据报首部(20))大小,也就是我们上图中的蓝色区域中的MSS分组的大小别最好超过这个大小,其实解释到这里,就好说清楚我们抓包之前抓包获取的mss = 1460是什么了,你现在可能会惊讶的发现这个值不刚好是上面的MTU减去各个头部的长度么!的确是,所以我们期望的MSS分组的大小也就是这个mss的大小。既然我们都可以算出这个大小了,为什么还要在tcp传输过程中再次告诉我们这个大小呢?原因是如果我们在公网传输中,可能会经过好多个中间路由或网关,而这些的MTU的值是不一样的,木桶效应,我们的mss为我们选了其中最小的MTU限制!
6.recv接口
recv接口用来接受发送端发来的数据,其详细接口如下
ssize_t recv(int sockfd,void *buf,size_t len,int flags);
//成功返回收到的字节数,失败返回错误
参数详解:
.sockfd为用于接受的套接字
.buf为用于获取数据的应运层buf
.len为2中buf的大小
其中参数flag我用到的如下
flags | 具体说明 |
---|---|
MSG_DONTWAIT | 此次调用为非阻塞 |
MSG_WAITALL | 在读到指定数量及recv中len字节后,调用才返回 |
MSG_PEEK | 窥探读缓存中的数据,此操作,不会导致读缓存中的数据被清空,主要用于多线程读同意套接字 |
MSG_OOB | 接受带外数据 |
图片来自百度
当我们在调用recv读套接字时,内核为我们维护了4个队列,其具体用途如下
队列 | 用途 |
---|---|
receive队列 | 当网卡收到tcp报文之后,此时如果接受缓冲区需要的下一个报文段就是它的话则,把该报文段的内容放入此队列中,如上图中第一次接受到的s1-s2就直接放入此队列 |
out_of_order | 如上,当网卡中收到的不是接受缓冲区中需要的下个报文段则放入此队列中,如上图中的第二步,收到了s3-s4则放入此队列中 |
prequeue队列 | 当recv没数据处于阻塞时,新来的有序数据会放入此队列中并唤醒recv |
backlog队列 | 当用户正在读取队列时(receive或prequeue)新来的数据将会被存入此队列中,此队列不一定有序 |
那么我们调用阻塞recv时,什么时候才可以读返回?
接受缓冲区的默认低水位为1,当我们所读到的字节(已经从内核态拷贝到用户态的数据)大于这个值时,理论上就可以返回了,当然出于高效的缘故,此时会检查一下backlog队列中是否有可以读走的数据(因为在我们拷贝的时候新到的数据会存在这)有的话我们就在处理一下它在返回,没有的话就直接返回
关于接受缓冲区的低水位问题,当接受缓冲区所拥有的数据大于低水位,则可读事件发生,当我们调用阻塞recv读取的数据超过低水位所限定的值,则recv就具备了返回的条件,我们可以通过setsockopt来设置读低水位的大小
下面的实例中,我们将客户端的sockfd低水位值设为2048,当,服务器给我们发送了12个字节之后,我们的recv始终不能成功返回,因为它不满足返回的条件,条件如上
server端
#include <stdio.h>
#include <muduo/net/TcpServer.h>
#include <muduo/net/EventLoop.h>
#include <muduo/net/InetAddress.h>
#include <muduo/net/TcpConnection.h>
#include <muduo/base/Timestamp.h>
#include <muduo/net/Buffer.h>
#include <string>
#include <iostream>
#include <muduo/base/StringPiece.h>
//新来连接和其断开时调用
void connectionCallback(const muduo::net::TcpConnectionPtr &conn)
{
printf("连接建立\n");
conn->send("hello,world\n");
printf("发送完成\n");
}
int main(int argc,char **argv)
{
muduo::net::EventLoop loop;
muduo::net::InetAddress listenAddr(argv[1],static_cast<uint16_t>(atoi(argv[2])));
muduo::net::TcpServer server(&loop,listenAddr,"tcpServer",muduo::net::TcpServer::kReusePort); //设置为重用端口
//设置给新连接的各种回调
server.setConnectionCallback(connectionCallback);
server.start();
loop.loop();
return 0;
}
client端
#include<sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
int main(int argc,char **argv)
{
if(argc <= 2)
{
printf("参数有误\n");
}
const char *ip = argv[1];
int port = atoi(argv[2]);
struct sockaddr_in address;
bzero(&address,sizeof(address));
address.sin_family = AF_INET;
inet_pton(AF_INET,ip,&address.sin_addr);
address.sin_port = htons(port);
int sockfd = socket(AF_INET,SOCK_STREAM,0);
//设置接受套接字的伐值
int lowat = 2048;
int len = sizeof(lowat);
//
setsockopt(sockfd,SOL_SOCKET,SO_RCVLOWAT,&lowat,sizeof(len));
if(connect(sockfd,(struct sockaddr *)&address,sizeof(address)) < 0)
{
printf("连接失败\n");
}
char buf[2048];
int ret = read(sockfd,buf,sizeof(buf));
printf("接收到字节数:%d\n",ret);
close(sockfd);
return 0;
}
7.send和recv阻塞与非阻塞对调用的影响
(1)首先谈send
如果套接字是阻塞的,由于我们的send的len参数给定了具体要写的字节数,所以send调用会把这个len长度的数据写完,如果发送缓冲区已满,那么它就会阻塞,等缓冲区腾出位置它又继续写,知道写完len个长度
如果套接字是非阻塞的,那么如果缓冲区空间足够,它和非阻塞写没区别,如果在send的过程缓冲区满了,则它会返回已写入的字节数
正因为阻塞和非阻塞的这种区别,我们在写服务端时,如果使用了I/O复用,那么我们的套接字描述符必须要设为非阻塞,因为如果设成阻塞,那么我们在写数据当发送缓冲区满时,send调用就会阻塞等待,这样其他套接字请求就得不到处理
(2)recv
当套接字是阻塞的时,如果调用recv之后此时接受缓冲区中数据没有达到接受缓冲区的低水位值,则recv阻塞,直到有数据量超过低水位值,该调用才具备返回条件,这个条件6中有说,才返回
非阻塞套接字和阻塞套接字的唯一区别在于,如果调用recv,非阻塞套接字对应的接受缓冲区中无数据可读,那么recv调用就会直接返回
8.close和shutdown接口
close接口用来关闭sockfd,其详细接口如下
int clsoe(int fd);
//成功返回0,失败返回-1
需要注意的点如下
1)close在多线程中关闭套接字时,会直接将其关闭掉,由于多线共享该套接字,而在多进程情况下,由于父子进程是通过拷贝来继承socket的,没拷贝一次socket的引用计数就会加1,当调用close时,引用计数减1,当引用计数为0时才会真的关闭该socket
2)tcp是个双开工协议,双开工是说对于同一个套接字,在同一时刻,我们可以同时对其进行读写操作而互相不影响,tcp的这个特性使得我们在正常close一个连接时需要4次挥手
3)如果close时存在一些异常情形,则通过给对方发送RST复位通知对方关闭连接,此时的异常情形指,关闭连接时,接受缓冲区中还有数据,或发送缓冲区中还有数据,此些情况下close调用会将数据直接丢弃
4)当关闭的套接字为监听套接字时,调用close会丢弃syn队列上的半连接,然后发送RST复位通知对端也关闭连接
9.connect接口
connect接口用于向有监听socket的进程发起连接,其详细接口如下
int connect(int sockfd,const struct sockaddr *serv_addr,socklen_t addrlen);
//成功返回0
参数详解
.sockfd为connect成功之后用来通信的socket
.serv_addr为要连接的地址
.addrlen为2中地址的大小
补充说明一点,如果我们在connect之前给sockfd手动进行的socket命名(bind绑定)则建立连接之后,服务器获取的是我们绑定的地址,否则,内核会自动调用bind为我们绑定地址,到时候服务器获取的我们的地址就不是确定的了(主要是端口不确定)
10.setsockopt操作一些socket常用属性
我们可以用setsockopt设置一些socket套接字的常用属性,其接口如下
int setsockopt(int sockfd,int level,int option_name,const void *option_value,socklen_t option_len);
参数详解
.socket为要操作的socket
.level为要操作哪种协议的某种属性,最常见的有TCP,UDP,IPV4,IPV6等,我平时接触的一般为这些协议通用的属性,所以使用SOL_SOCKET作为实参
.option_name所要操作属性的名字,例如要改变socket的接受缓冲区的大小,则传入实参SO_RCVBUF
.option_value改变属性值的大小,还是上述改变接受缓冲区大小,如果我要把他的值变为3000,则就把3000传给该参数
.option_len上一条变量的占空间的大小