Posix API
Posix API与网络协议栈
- Posix API与网络协议栈
- socket
- bind
- listen
- accept
- connect
- recv和send
整个网络之间的通信贯穿起来都离不开网络协议栈这个东西,网络协议栈主要负责主要负责网络间数据的通信,自顶向下可分为五层:应用层,传输层,网络层,数据链路层和物理层。
网络协议栈各部分所处位置与操作系统有这密切的关系:
- 应用层是位于用户层的。 这部分代码是由网络协议的开发人员来编写的,比如HTTP协议、HTTPS协议以及SSH协议等。
- 传输层和网络层是位于操作系统层的。其中传输层最经典的协议叫做TCP协议,网络层最经典的协议叫做IP协议,这就是我们平常所说的TCP/IP协议。
- 数据链路层是位于驱动层的。 其负责真正的数据传输。
客户端与服务端之间的交互,遍布整个网络协议栈,接下来我们来介绍一些相关的接口
Posix API与网络协议栈
socket
int socket(int domain, int type, int protocol);
创建套接字的接口,参数如下:
domain
:创建套接字的域或者叫做协议家族,也就是创建套接字的类型。该参数就相当于cpp struct sockaddr结构的前16个位。如果是本地通信就设置为AF_UNIX
,如果是网络通信就设置为AF_INET
(IPv4)或AF_INET6
(IPv6)。type
:创建套接字时所需的服务类型。其中最常见的服务类型是SOCK_STREAM
和SOCK_DGRAM
,如果是基于UDP的网络通信,我们采用的就是SOCK_DGRAM
,叫做用户数据报服务,如果是基于TCP的网络通信,我们采用的就是SOCK_STREAM
,叫做流式套接字,提供的是流式服务。protocol
:创建套接字的协议类别。你可以指明为TCP或UDP,但该字段一般直接设置为0就可以了,设置为0表示的就是默认,此时会根据传入的前两个参数自动推导出你最终需要使用的是哪种协议。
返回值:
- 套接字创建成功返回一个文件描述符,创建失败返回-1,同时错误码会被设置。
socket
接口的作用我们就可以理解为两个:
- 分配 fd ,在网络通信中,一般都是以 fd 的形式来进行的,创建套接字,就相当于分配了一个 fd ,打开了一个文件,他其实就是使用的 bitmap 的形式来进行分配的,从前往后,某个文件描述符被使用就将其占用的 bit 为置为1。
- 分配一个 tcb 控制块,主要就是对当前任务的一个描述,注意,这儿是并没有分配发送缓冲区和接收缓冲区的。
socke如何被调用?
我们们都是在用户层编写代码,soket 接口属于系统调用接口,也就是说,当一个可执行程序创建以后,此时操作系统就会为其分配一个进程,然后这个进程根据调用顺序,调用到我们的 socket 接口。
bind
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
绑定套接字,参数如下:
sockfd
:绑定的文件的文件描述符。也就是我们创建套接字时获取到的文件描述符。addr
:网络相关的属性信息,包括协议家族、IP地址、端口号等。addrlen
:传入的addr结构体的长度。
返回值说明:
- 绑定成功返回0,绑定失败返回-1,同时错误码会被设置。
对于 bind 接口来说,他的作用其实就是将 socket 分配的 fd,以及网络相关的属性信息(协议家族、IP地址、端口号)绑定到对应的 tcb 控制块上面,因为调用 socket 接口只是单纯的打开了一个文件,接下要来要如何做就需要 bind 接口来进行,实际上就是将文件与网络关联了起来。
struct sockaddr_in 结构体
/* Structure describing an Internet socket address. */
struct sockaddr_in{__SOCKADDR_COMMON (sin_);in_port_t sin_port; /* Port number. */struct in_addr sin_addr; /* Internet address. *//* Pad to size of `struct sockaddr'. */unsigned char sin_zero[sizeof (struct sockaddr)- __SOCKADDR_COMMON_SIZE- sizeof (in_port_t)- sizeof (struct in_addr)];};
sin_family
:表示协议家族。sin_port
:表示端口号,是一个16位的整数。sin_addr
:表示IP地址,是一个32位的整数。
在绑定之前我们就需要创建一个struct sockaddr_in
类型的结构体将对应的网络属性信息填充到这个结构体中,发送到网络之前需要将端口号设置为网络序列,由于端口号是16位的,因此我们需要使用前面说到的htons
函数将端口号转为网络序列。此外,由于网络当中传输的是整数IP,我们需要调用inet_addr
函数将字符串IP转换成整数IP,然后再将转换后的整数IP进行设置。
struct sockaddr_in serveraddr;
serveraddr.sin_family = AF_INET;
serveraddr.sin_addr.s_addr = htonl(INADDR_ANY); // 0.0.0.0
serveraddr.sin_port = htons(port); // 0 ~ 1023
INADDR_ANY
/* Address to accept any incoming messages. */
#define INADDR_ANY ((in_addr_t) 0x00000000)
我们使用的服务器对应的 IP 不一定就是公网 IP ,那么就会存在不能被直接绑定的问题,就不能被外网所访问,此时就需要 bind 0 ,而INADDR_ANY
其实就是0,在绑定INADDR_ANY
以后,此时我们的服务器就可以被外网所访问了。
绑定INADDR_ANY
也是有好处的,一个服务器可能会对应多张网卡,多张网卡也就意味着可能会存在多个 IP 地址,但是一台服务器对应比如说 2000 这个端口号只有一个,如果此时服务器绑定了某个 ip 地址,那么就只有该 IP 地址的数据可以通过 2000 这个端口发送给服务器,其他网卡的数据发送不了,绑定了INADDR_ANY
以后,也就代表着任意网卡的数据发送来以后,都可以被 2000 这个端口接收到发送给服务器。
listen
int listen(int sockfd, int backlog);
设置套接字的监听状态,参数说明:
sockfd
:需要设置为监听状态的套接字对应的文件描述符。backlog
:全连接队列的最大长度。如果有多个客户端同时发来连接请求,此时未被服务器处理的连接就会放入连接队列,该参数代表的就是这个全连接队列的最大长度,一般不要设置太大,设置为5或20即可。
返回值说明:
- 监听成功返回0,监听失败返回-1,同时错误码会被设置。
listen 接口实际上就是将对应的 tcb 控制块中的 TCP_STATUS 由 CLOSED 设置为 LISTEN这个状态,如下图所示:
我们就可以理解为,在进行了前两步操作以后,此时如果没有 listen 此时连接请求是会被拒绝的,举个例子,你要找酒店前台小姐姐,但是当前前台小姐姐正在培训,你就找不到,只有他正式上岗以后,你才可以找到他,正式上岗的这个操作就相当于 listen。
理解listen的第二个参数
客户端调用 connect 函数以后,此时就代表了三次握手的开始,三次握手是一个持续的过程,并不是发生在某一个调用接口里面,当服务端接收到第一次握手请求的时候,此时就会保存一个半连接队列,然后继续走流程,直到第三次握手结束时刻,此时半连接队列就会切换成为全连接队列,全连接队列的数量正是由 listen 的第二个参数进行控制的,也就是他的参数 + 1。其实从 TCP 协议的演变过程中,这个参数也在变化(syn 半连接队列数量 -> syn 半连接队列数量 + accept 全连接队列数量 -> accept 全连接队列数量)
accept
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
参数说明:
sockfd
:特定的监听套接字,表示从该监听套接字中获取连接。addr
:对端网络相关的属性信息,包括协议家族、IP地址、端口号等。addrlen
:调用时传入期望读取的addr结构体的长度,返回时代表实际读取到的addr结构体的长度,这是一个输入输出型参数。
返回值说明:
- 获取连接成功返回接收到的套接字的文件描述符,获取连接失败返回-1,同时错误码会被设置
对于 accept 来说,他其实就是建立连接的一个过程,他的作用之一也是分配 fd ,我们将监听套接字的 fd 传进去以后,他会给我们返回一个 fd,这个才是真实进行事件处理的 fd ,第二个作用就是建立fd 与 tcb 控制块之间的映射关系。
理解 accept 通过 ET 模式实现
在 recator 的实现中,我们运用的 epoll 相关的方法,我们知道,epoll 分为 LT 工作模式与 ET 工作模式,对于 accept 来说,他好像是更适用于 LT 模式来进行实现的,因为只要时间一发生,就会通知进行处理,非常的适用于 accept 接口。
那么,在 ET 工作模式中如何操作 accept 呢?其实也很简单,运用循环的方式进行实现,直到最终给我们返回的值为 -1 了,当前就可以 break 掉了,我们来看一段伪代码:
connect
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
发起连接请求的函数,参数如下:
sockfd
:特定的套接字,表示通过该套接字发起连接请求。addr
:对端网络相关的属性信息,包括协议家族、IP地址、端口号等。addrlen
:传入的addr结构体的长度。
返回值说明:
- 连接或绑定成功返回0,连接失败返回-1,同时错误码会被设置。
对于 connect 来说,他是用于客户端的接口,在客户端调用 connect 接口以后,此时就会进行三次握手的流程了,需要注意的是,三次握手只能是由客户端发起,服务端是不会发起三次握手的。
recv和send
ssize_t recv(int sockfd, void *buf, size_t len, int flags);ssize_t send(int sockfd, const void *buf, size_t len, int flags);
参数如下:
sockfd
:特定的文件描述符,表示从该文件描述符中读取数据。buf
:数据的存储位置,表示将读取到的数据存储到该位置。调用 send 时就表示发送的数据是存到该位置的。count
:数据的个数,表示从该文件描述符中读取数据的字节数。flags
:一般都设置为0;
在之前的系统编程中,我们学习过 write/read
函数,其实作用跟这两个接口是一样的,我们回忆一下当中的逻辑:
- 读端一直读,写端不写了,读端读取完毕以后就阻塞住;
- 写端一直写,读端不读,此时写满了也就不会再写了,也会阻塞住;
- 写端关闭,读端将数据读取到 0 以后也会阻塞住;
- 读端关闭,此时就系统就会直接将写端 kill 掉,因为读端不会再读取数据了。
我们还需要注意,TCP/UDP 两种模式下他们发送数据的方式是不一样的,一个是面向报文的形式,一个是面向字节流的形式,
如何理解面向数据报?
对于 UDP 协议来说,是不会进行报文分割的,也就是说,应用层交给 UDP 多长的报文,UDP 就会原模原样的发送给内核,并不会分割,举个例子:应用层发送 100 个字节的数据,那么 UDP 就会将这100 个字节直接发送给内核,这就叫面向数据报。
如何理解面向字节流?
对于 TCP 协议来说,会存在报文分割的现象,也就是说,应用下发报文以后, TCP 并不会向 UDP 那样,直接一股脑的全部发出去,他如果识别到报文太长的话,就会将其进行分割,分成几部分分开进行发送,如果对应的报文太短,就会先保存先来,等到足够长度在一起发送。
对于recv/send/read/write
等这些函数来说,根本意义上他们其实属于一种拷贝函数,那么如何去理解呢?
以 TCP 通信为例,调用 socket 通信以后,内核中会创建一个发送缓冲区与一个接收缓冲区,当调用 send 函数发数据时,就会将这些数据写入到这个发送缓冲区当中,此时 send 函数就返回了,这些数据就由 TCP 自行进行处理,至于怎么发就向上面所描述的一样。
接收也是,网卡驱动会将数据先发到接收缓冲区当中,然后应用调用 recv 函数读取接收缓冲区当中的数据,这里也是可以按任意字节数据的数据进行读取的,这种对于 TCP 来说,他并不关心你发过来的是什么数据,他只关心自己只要将数据放进对应的接收缓冲区即可,在他看来就是一个一个字节的数据,至于发什么,他并不会去管,这种就叫面向字节流。
注意,UDP 协议中并不会存在真正意义上的发送缓冲区,因为 UDP 是面向数据报的,但是会有对应的接收缓冲区,因为如果上一次的数据还没有处理完毕,就会将这次的数据暂存于接收缓冲区当中,等待上一次数据处理完毕在进行处理,防止丢包的现象发生。