Linux协议栈posix接口浅析

Linux协议栈与posix api的关系

众所周知,Linux内核协议栈Linux内核的网络管理模块的具象化表达实例,用户空间如果想使用Linux内核提供的网络服务就需要使用内核提供的一系列网络相关的posix接口(操作系统原语,或者说原生接口)。实际上,当前posix网络接口(套接字接口)的API是基于4.4 BSD套接字接口的。尽管这些年套接字接口有些细微的变化, 但是当前的套接字接口与20世纪80年代早期4.2BSD所引入的接口很类似。

以TCP通信举例,我们想开发一套基于C/S通信架构的服务,需要获取一个tcp的socket,该socket本质上是一个文件描述符,也可以说是一个内核中tcb(tcp control block)的映射。当我们在应用层调用connect,send等posix相关的api(系统调用)时,内核就会操作tcb的传输内存,按照tcp协议的标准,进行握手建立连接并发送用户数据。

本文旨在通过介绍这些posix api,分析用户态接口表达与深层内核操作的联系,帮助我们更好的理解linux内核网络模块。

客户端&通用posix接口

客户端通常有以下常用接口,在服务端相同的功能实现上,poxis提供的是同一套接口,因此服务端部分仅介绍其独有的posix api;

socket

套接字是通信端点的抽象。正如使用文件描述符访问文件, 应用程序用套接字描述符访问套接字. 套接字描述符在unix/linux系统中被当作是一种文件描述符。事实上, 许多处理文件描述符的函数(如read和l write)可以用于处理套接字描述符。

@domin		:域,用于确定通信的特性,可以使用AF_xx宏,AF就是只address family,地址族
				AF_INET :ipv4因特网域
				AF_INET6:ipv6因特网域
				AF_UNIX :UNIX域
				AF_UPSPEC:未指定
				
@type		:确定套接字的类型, 进一步确定通信特征
				SOCK_DGRAM		:固定长度的、无连接的、不可靠的报文传递
				SOCK_RAW		:IP协议的数据报接口〈在POSIX1中为可选)
				SOCK_SEQPACKET	:固定长度的、有序的、可靠的、面向连接的报文传递
				SOCK_STREAM		:有序的、可靠的、双向的、而向连接的字节流
				
@protocol	:通常是0, 表示为给定的域和套接字类型选择默认协议。
				如果domin是AF_INET,type是SOCK_STREAM,protocol就是tcp
				如果domin是AF_INET,type是SOCK_DGRAM,protocol就是udp
				您也可以指定其值,但是需要符合协议标准。
				IPPROTO_IP:IPv4网际协议
				IPPROTO_IPV6:IPv6网际协议(在POSX.1 中为可选
				IPPROTO_ICMP:因特网控制报文协议(Internet Control Message Protocol)
				IPPROTO_RAW:原始IP数据包协议(在POSIX.1中为可选〉
				IPPROTO_TCP:传输控制协议
				IPPROTO_UDP:用户数据报协议(User Datagram Protocol)

int socket (int domain, int type, int protocol);
//返回值: 若成功, 返回文件〈套接字〉描述符. 若出错, 返回-1

调用 socket 与调用open相类似。在两种情况下,均可获得用于IO的文件描述符。当不再需要该文件描述符时,调用close 来关闭对文件或套字的访问,并且释放该描述符以便重新使用。

虽然套接字描述符本质上是一个文件描述符,但不是所有参数为文件描述符的函数都可以接受套接字描述符。例如,lseek不能以套接字描述符为参数,因为套接字不支持文件偏移量的概念。

同时,调用socket后,内核为新的套接字分配一个唯一的文件描述符,并返回该文件描述符给应用程序。同时分配数据结构,内核在内存中为该套接字分配相应的数据结构(如sock结构体),并将其与该文件描述符相关联。这些数据结构包含有关套接字状态、传输控制块、缓冲区等信息。

shutdown

套接字通信是双向的。可以采用 shutdown 函数来禁止一个套接字的 IO。

@sockfd	:被操作的socket fd
@how	:如何关闭
			SHUT_RD:无法从套接字读取数据
			SHUT_WR:无法使用套接字发送数据
			SHUT_RDWR:则既无法读取数据,又无法发送数据
#include <sys/socket,h>
int shutdown (int sockfd,int how);
//返回值:若成功,返回 0;若出错,返回-1

这里也要引出一个问题,既然**close()**能够关闭一个套接字,为何还使用 shutdown 呢?这里有若干理由。

首先,只有最后一个活动引用关闭时,close 才释放网络端点。这意味着如果复制一个套接字(如采用 dup),要直到关闭了最后一个引用它的文件描述符才会释放这个套接字。而 shutdown 允许使一个套接字处于不活动状态,和引用它的文件描述符数目无关。

其次,有时可以很方便地关闭套接字双向传输中的一个方向。例如,如果想让所通信的进程能够确定数据传输何时结束,可以关闭该套接字的写端,然而通过该套接字读端仍可以继续接收数据。

但是使用close()关闭socket在相当多的历史工程中得到了应用,为了工程的稳定性,有时我们又不得不选择close;

setsockopt

套接字机制提供套接字选项setsockopt接口来控制套接字行为。

@sockfd:socket
@level:参数 level 标识了选项应用的协议。如果选项是通用的套接字层次选项,则 level 设置成SOL_SOCKET。
否则,level 设置成控制这个选项的协议编号。对于TCP 选项,level是IPPROTO_TCP对于IP,level是IPPROTO_IP。
@option:通用套接字层次选项,详见下图
@val:根据选项的不同指向一个数据结构或者一个整数。一些选项是 on/off开关。如果整数非0,则启用选项。如果整数为0,则禁止选项。
@len:参数len指定了val 指向的对象的大小。

int setsockopt(int sockfd, int level, int option, const void *val,socklen_t len);
//返回值:若成功,返回0:若出错,返回-1

在这里插入图片描述
无独有偶,我们也可以使用getsockopt接口获取套接字状态。

bind

该接口用于绑定地址和端口号,如果应用程序调用bind函数绑定了地址和端口号,则内核将相应地设置套接字的本地IP地址和端口号。否则,系统会自动分配一个可用的未绑定端口号。

int bind(int sockfd, const struct sockaddr *addr, socklen_t len);
//返回值:若成功,返回 0:若出错,返回-1

对于使用的地址有以下一些限制。

  • 在进程正在运行的计算机上,指定的地址必须有效;不能指定一个其他机器的地址。 地址必须和创建套接字时的地址族所支持的格式相匹配。
  • 地址中的端口号必须不小于1024,除非该进程具有相应的特权(即超级用户)。一般只能将一个套接字端点绑定到一个给定地址上,尽管有些协议允许多重绑定。对于因特网域,如果指定IP地址为 INADDR_ANY (<netinet/in.h>中定义的),套接字端点可以被绑定到所有的系统网络接口上。这意味着可以接收这个系统所安装的任何一个网卡的数据包。

connect

@sockfd:发起连接使用的socket
@addr:发起连接的设备信息结构体指针
@len:结构体大小
int connect(int sockfd,const struct sockaddr *addr, socklen_t len);
//返回值:若成功,返回 0;若出错,返回-1

connect用于发起连接,在addr中填充好连接信息后,就可以通过该接口向服务器发起三次握手建立连接。大致过程如下:

当应用程序调用connect()函数时,内核会根据指定的目标地址和端口号创建一个TCP套接字,并向远程主机发送SYN包以发起连接请求。此时开始第一次握手。
如果远程主机接收到了SYN包并愿意建立连接,则它会向本地主机发送一个ACK包作为应答,并同时发送自己的SYN包以确认连接请求。此时开始第二次握手。
当本地主机接收到了来自远程主机的SYN和ACK包后,会向远程主机发送一个ACK包以确认连接已经建立。此时完成了第三次握手。

在 connect 中指定的地址是我们想与之通信的服务器地址。如果 sockfd 没有绑定到一个地址,connect会给调用者绑定一个默认地址。
当尝试连接服务器时,出于一些原因,连接可能会失败。要想一个连接请求成功,要连接的计算机必须是开启的,并且正在运行,服务器必须绑定到一个想与之连接的地址上,并且服务器的等待连接队列要有足够的空间(详见下文listen) 。因此,应用程序必须能够处理connect 返回的错误,这些错误可能是由一些瞬时条件引起的。

send

既然一个套接字端点表示为一个文件描述符,那么只要建立连接,就可以使用 read和 write来通过套接字通信。在套接字描述符上使用 read 和 write 是非常有意义的,因为这意味着可以将套接字描述符传递给那些原先为处理本地文件而设计的函数。而且还可以安排将套接字描述符传递给子进程,而该子进程执行的程序并不了解套接字。

尽管可以通过 read 和 write 交换数据,但这就是这两个函数所能做的一切。如果想指定选项,从多个客户端接收数据包,或者发送带外数据,就需要使用6(三个发送函数,三个配套的接收函数) 个特定函数。

ssize_t send(int sockfd, const void *buf, size_t nbytes, int flags);
//返回值:若成功,返回发送的字节数:若出错,返回-1

flag的定义如下图所示:
在这里插入图片描述
值得一提的是,send即使返回了发送成功的字节数,也只能代表数据已经无误的拷贝到了内核协议栈的发送缓冲区队列,或者理解为发送到了网络驱动程序上,并不能代表接收端已经接收到了发送的字节数。

ssize_t sendto(int sockfd, const void *buf, size_t nbytes, int flags,const struct sockaddr *destaddr,  socklen_t destlen);
//返回值:若成功,返回发送的字节数:若出错,返回-1

sendto与send类似,但是他可以在一个无连接的套接字上指定一个确定的目标地址。

ssize t sendmsg(int sockfd, const struct msghdr *msg, int flags);
//返回值:若成功,返回发送的字节数;若出错,返回-1

该接口可以指定多重缓冲区传输数据,类似于writev函数。

recv

当从套接字上接收数据时,可以直接从socket上read数据。与read类似,接收接口有以下api可供选择:

ssize_t recv(int socfd, void *buf, size_t nbytes, int flags);
//返回值:返回数据的字节长度:若无可用数据或对等方已经按序结束,返回0:若出错,返回-1

flags的详细定义可看下图:
在这里插入图片描述

ssize_t recvfrom(int sockfd, void *restrict buf, size_t len, int flags,struct sockaddr *restrict addr, socklen_t *restrict addrlen);
//返回值:返回数据的字节长度:若无可用数据或对等方已经按序结束,返回0:若出错,返回-1

与recv不同的是,recvfrom可以根据出参addr的内容获取发送者的五元组信息;

ssize_t recvmsg(int sockfd, struct msghdr *msg, int flags);
//返回值:返回数据的字节长度;若无可用数据或对等方已经按序结束,返回 0;若出错,返回-1

recvmsg用msghdr 结构指定接收数据的输入缓冲区。可以设置参数flags来改变recvmsg 的默认行为。返回时,msghdr 结构中的msg_flags 字段被设为所接收数据的各种特征。

close

该接口用于关闭套接字,当应用程序调用close函数关闭套接字时,内核会释放该套接字所占用的资源,并将其从内核数据结构中移除。
接口也很简单,直接close(sockfd)即可。

服务端posix接口

listen

int listen(int sockfd,int backlog);
//返回值:若成功,返回 0;若出错,返回-1

服务端调用listen宣告它接受连接请求,其参数backlog提供了一个提示,提示该进程所要入队的未完成连接的请求数量。为什么说是提示呢,因为根据Linux内核的不断迭代,实际上该socket可以容纳的未连接请求远远不止传入的这个数目,其数量可以自适应增加,但不会超过其上限:<sys/socket.h>中的SOMAXCONN;

accept

一旦服务端调用了listen让主动套接字变被动监听套接字,就可以使用accept函数获得连接请求并建立连接;

@sockfd:服务端socket,该socket仅用于接收连接请求,获取的新socket的fd与之无关。
@addr:一个指向struct sockaddr类型变量的指针,用于存储客户端地址信息。
@len:一个指向socklen_t类型变量的指针,表示addr结构体长度。
int accept(int sockfd, struct sockaddr *restrict addr,
socklen_t *restrict len)//返回值:若成功,返回文件 (套接字) 描述符;若出错,返回-1

当服务器程序调用accept()时,如果此时有客户端发送了连接请求,那么它将立即从内核中已经建立的全连接队列中,寻找该请求的五元组信息。如果确认该客户端已经处于全连接队列,那么就返回新分配给该客户端所用的套接字文件描述符。

此时服务器进程就可以利用该文件描述符与该客户进程进行通信了。同时,在accept()函数中会填充传入参数中对应结构体(如addr)的内容,以获得远程主机地址信息等相关信息。

需要注意以下几点:

当没有连接请求到达时,accept()函数会一直阻塞等待,并且只有在监听套接字上有连接请求时,它才会返回新的套接字描述符,可以将socket设置为非阻塞从而马上获得获取结果。
由于accept()函数是阻塞式函数,因此可能会导致服务器程序被挂起,为了避免这种情况的出现,可以通过使用多线程或者多路复用等技术来实现同时处理多个客户端请求。
accept()函数只能用于监听TCP协议的连接请求,不能用于UDP协议。

posix接口与协议栈的交互

根据上文的接口介绍可知,posix接口调用后,会对内核协议栈产生影响,透过现象追本质,掌握这些具体细节,可以让我们更深刻的理解协议栈的具体实现方法,提高解决问题的能力以及技术深度。

接下来以最常用的tcp举例,分析posix接口与协议栈的交互过程。

tcp状态机

首先拿出理论支持,该图片提供了tcp协议状态迁移的完整状态机。
在这里插入图片描述

tcp状态机迁移与posix的对应关系

以一个tcp客户端调用connect()发起连接请求开始分析:

客户端调用connect函数向服务端发起连接请求,本质上就是向目标socket发送SYN建流请求,内核组合一个SYN置位的tcp请求包向目标地址发送过去,本身进入SYN_SENT状态。
之后,处于LISTEN状态的服务端socket收到该包,解析后发现是SYN建流,便收SYN,发自己的SYN&ACK,进入SYN_RCVD状态;
客户端收到了服务端的SYN以及回复的ack,返回应答ack,进入ESTABLISHJED(已建立)状态。服务端收到了ACK,也进入ESTABLISHJED状态,三次握手完成,建立双向通路。

之后客户端与服务端进行了若干次通信,实质上就是数据通过网卡到协议栈,进行封包拆包,将数据拷贝的过程(域套接字整体过程会简单很多),不做过多解释。

之后,客户端请求断开连接,调用了close()函数。

客户端向服务端目标套接字发送FIN包,请求断开连接,本身进入FIN_WAIT_1阶段。
服务端收到FIN请求,之后返回ack,之后进入CLOSE_WAIT状态,开始处理资源的释放。
服务端完成了资源的释放以及其他业务需求,调用了close,同样向客户端发送FIN包,进入LAST_ACK状态;
客户端收到了服务端的FIN请求,进入FIN_WAIT_2状态,返回服务端的ack,达到TIME_WAIT的状态,挥手完成。

上述只是正常的客户端请求断开,服务端被动断开连接的过程,实际应用上,还会有若干问题,下一节简单的挑一些展开讨论;

如何解决常见的网络问题

服务端为何会出现大量的close_wait状态?

答:主要是服务端收到了大量的断开连接请求,但是由于业务复杂,断开请求需要一个个处理,所以后续的断开难以马上得到处理。我们可以借鉴生产消费模型,将资源释放相关业务交给另一个线程去做,服务端直接断开对应的连接fd。达到网络数据和业务分离的效果,实际上也是理论模块和业务模块的解耦。

CS双方同时调用close,会出现什么状况?

答:状态机上可以看到客户端和服务端同时调用close的状况
如果双方都发送了FIN包进入了FIN_WAIT_1的状态,此刻收到了对方的FIN请求,则会发送ACK报文进入closing状态,之后收到对方的ACK,则进入TIME_WAIT状态结束挥手。

为什么会有TIME_WAIT这个状态

答:TIME_WAIT这个等待时间主要是为了确保服务器已经成功地接收到了客户端发来的ACK包,并且该ACK包没有在网络中迷失或延迟到达。如果在这个等待时间内客户端再次试图建立相同的连接,则会被认为是之前连接的重复尝试,并且根据TCP协议规定直接拒绝该请求。

另外,在TIME_WAIT状态期间,客户端也可以接收到服务器发来的数据包。如果这些数据包与之前连接有关,则可以通过重新建立一个新的TCP连接将其传输给应用程序。

总之,TIME_WAIT状态是一种必要的机制,用于确保TCP连接能够正常、安全地结束。

send过程中,网线断了或者网络设备停电了怎么办,如何定位问题?

答:由于收发过程是由操作系统内核协议栈完成的,具体情况应该具体分析。
如果是电源线断了,发送会马上终止,因为内存数据掉电丢失,定位这个故障可以采用发送心跳包的方式,定期内没收到心跳包则可以判断是发送端数据发送终止,从而排查问题。

如果是设备重启,则内核会做相关策略,将掉电前未完成的任务继续进行,这种机制在很多中间件中也很常见。

如果是网络设备路由器等掉电,可以通过网络服务探测网络状况,定位问题。

本专栏知识点是通过<零声教育>的系统学习,结合APUE及自身经验,进行梳理总结写下的文章,对c/c++linux课程感兴趣的读者,可以去零声官网查看详细的服务,也欢迎一起蹭免费公开课,共同进步~

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

我叫大魔宝

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值