400-035-6699
当前位置: 首页 » 技术支持 » 博文资讯 »

Linux系统调用:揭秘高性能网络编程背后的机制

网络通信中,我们常常需要处理字节序问题。字节序是指多字节数据在内存中的存放顺序,分为大端和小端。大端模式是高位字节存放在低地址处,而小端模式是低位字节存放在低地址处。
由于网络协议规定使用大端字节序进行数据传输,因此在进行网络通信时,我们需要将主机字节序转换为大端字节序。在C语言中,我们可以使用`htonl()`和`ntohl()`函数来进行长整型的转换,使用`htons()`和`ntohs()`函数来进行短整型的转换。
`htonl()`函数将主机字节序的长整型数据转换为大端字节序,而`ntohl()`函数则将网络字节序的长整型数据转换为主机字节序。同样,`htons()`和`ntohs()`函数分别用于短整型的转换。
除了字节序问题,我们还经常需要处理socket地址。socket地址包含了协议簇和存储数据两个部分。协议簇是指使用的网络协议类型,例如PF_UNIX、PF_INET和PF_INET6等。而存储数据则是根据协议簇来确定的,例如UNIX本地路径、IPv4端口和IP、IPv6端口和IP等。
在C语言中,我们可以使用`sockaddr`结构体来表示socket地址。`sockaddr`结构体包含了协议簇和存储数据。对于不同的协议簇,我们还可以使用专门的结构体来表示,例如`sockaddr_un`表示UNIX本地协议簇,`sockaddr_in`表示IPv4本地协议簇,`sockaddr_in6`表示IPv6本地协议簇。
socket的创建、绑定、监听、接受连接、连接、关闭和读写数据等操作是网络开发必备知识。这些操作涉及到`socket`、`bind`、`listen`、`accept`、`connect`、`close`和`shutdown`等函数。
`socket`函数用于创建一个socket,`bind`函数用于将socket绑定到特定的IP和端口上,`listen`函数用于监听来自客户端的连接请求,`accept`函数用于接受客户端的连接请求,`connect`函数用于客户端发起连接请求,`close`函数用于关闭socket,`shutdown`函数用于终止socket连接。
在读写数据方面,TCP使用`recv`和`send`函数,UDP使用`recvfrom`和`sendto`函数。这些函数可以设置不同的flags来控制读写操作的行为,例如发送紧急数据、不阻塞操作、等待所有数据等。
除了基本的读写操作,还有一些高级的I/O函数可以使用。例如`pipe`函数可以创建一个管道,用于进程间通信;`socketpair`函数可以创建一对socket,用于在同一主机上的进程间通信;`dup`和`dup2`函数可以复制文件描述符;`readv`和`writev`函数可以将分散的内存数据集中读写到文件描述符中;`sendfile`函数可以实现文件的零拷贝传输;`splice`函数可以在两个文件描述符之间移动数据。
在进行网络开发时,还需要了解一些socket选项。例如`SO_REUSEADDR`选项可以强制处于TIME_WAIT状态的socket句柄可以被bind;`SO_RECVBUF`和`SO_SENDBUF`选项可以设置socket句柄的发送缓冲区和接收缓冲区的大小;`SO_LINGER`选项可以控制close系统调用在关闭TCP连接时的行为。
通过理解和应用这些基础知识,我们可以更好地进行网络通信和数据传输。

第一部分:基础API

1、主机字节序和网络字节序

我们都知道字节序分位大端和小端:

Linux系统调用:揭秘高性能网络编程背后的机制

  • 大端是高位字节在低地址,低位字节在高地址
  • 小端是顺序字节存储,高位字节在高地址,低位字节在低地址
    既然机器存在字节序不一样,那么网络传输过程中必然涉及到发出去的数据流需要转换,所以发送端会将数据转换为大端模式发送,系统提供API实现主机字节序和网络字节序的转换。
#include < netinet/in.h >
// 转换长整型
unsigned long htonl(unsigned long int hostlong);
unsigned long ntohl(unsigned long int netlong);
// 转换短整型
unsigned short htonl(unsigned short int hostshort);
unsigned short ntohl(unsigned short int netshort);

2、socket地址

(1)socket地址包含两个部分,一个是什么协议,另一个是存储数据,如下:

struct sockaddr {
    sa_family_t sa_family; // 取值:PF_UNIX(UNIX本地协议簇),PF_INET(ipv4),PF_INET6(ipv6)
    char sa_data[14]; // 根据上面的协议簇存储数据(UNIX本地路径,ipv4端口和IP,ipv6端口和IP)
};

(2)各个协议簇专门的结构体

// unix本地协议簇
struct sockaddr_un {
    sa_family_t sin_family; // AF_UNIX
    char sun_path[18];
};

// ipv4本地协议簇
struct sockaddr_in {
    sa_family_t sin_family; // AF_INET
    u_int16_t sin_port;
    struct in_addr sin_addr;
};

// ipv6本地协议簇
struct sockaddr_in6 {
    sa_family_t sin_family; // AF_INET6
    u_int16_t sin6_port;
    u_int32_t sin6_flowinfo;
    ...
};

3、socket创建

socket,bind,listen,accept,connect,close和shutdown作为Linux网络开发必备知识, 大家应该都都耳熟能详了,所以我就简单介绍使用方式,重点介绍参数注意事项

#include < sys/types.h >
#include < sys/socket.h >

int socket(int domain, int type, int protocol);

(1)domain参数目的是告诉底层协议簇,选项(PF_INET, PF_INET6和PF_UNIX);
(2)type指定服务类型(流数据和数据报),选项(SOCK_STREAM和SOCK_UGRAM);
(3)protocol默认0即可;
注意:
socket的属性SOCK_NONBLOCKSOCK_CLOEXEC,分别标识非阻塞和fork子进程在子进程中关闭socket;

4、bind

#include < sys/types.h >
#include < sys/socket.h >

int bind(int sock, const struct sockaddr* addr, socklen_t addrlen);

有了socket句柄,我们需要将句柄绑定到某个IP上,所以参数分别是通过socket创建的句柄和转换后的struct sockaddr
注意:
(1)返回错误errno=EACCES:被绑定的地址是受保护的,比如端口0-1023不允许使用;
(2)返回错误errno=EADDRINUSE:被绑定的地址正在使用,比如socket被其他已经绑定了或者TIME_WAIT阶段;

5、listen

#include < sys/socket.h >

int listen(int sock, int backlog);

(1)socksocket的句柄;
(2)backlog在上一篇文章中讲过,是处于半连接和完全连接的sock上限;

6、accept

#include < sys/types.h >
#include < sys/socket.h >

int accept(int sock, struct sockaddr *addr, socklen_t addrlen);

(1)socksocket的句柄;
(2)addr用来获取建立连接后的对端的地址;
详细的accept建立连接流程,在上一篇文章也有详细讲过(可以重新翻阅一下), 这里要注意的是accept应该如何和与高性能结合,这里留个疑问,下一篇文章将会介绍《IO复用》会详细介绍。

7、connect

#include < sys/types.h >
#include < sys/socket.h >

int connect(int sock, const struct sockaddr *addr, socklen_t addrlen);

Client端发起连接的函数,socksocket的句柄,addr连接的唯一地址,这个函数使用的注意事项:
(1)返回ECONNREFUSED,标识目标端口不存在,连接被拒绝;
(2)返回ETIMEOUT,连接超时;

8、close和shutdown

#include < unistd.h >

int close(int fd);
int shutdown(int sockfd, int flag);

这两个函数的区别也在上一篇文章有提及,close不是真正关闭连接,只有fd引用计数为0才关闭,shutdown立即终止连接。
注意:
(1)shutdownflag=SHUT_RD,关闭连接的读端,不再执行读操作,socket的缓冲区数据都被清空;
(2)shutdownflag=SHUT_WR,关闭连接的写端,不再执行写操作,socket的缓冲区数据会在关闭之前全部发送出去;
(3)shutdownflag=SHUT_RDWR,关闭连接的读端和写端,其缓冲区数据处理如上;

9、读写数据

TCP读写数据:

#include < sys/types.h >
#include < sys/socket.h >

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);

这里要注意的是一些flags的使用:
(1)flags=MSG_OOB发送或者接收紧急数据;
(2)flags=MSG_DONTWAIT对socket此次操作不阻塞;
(3)flags=MSG_WAITALL读到指定大小的字节才返回;
(4)flags=MSG_MORE告诉内核还有更多数据发送,让内核等数据一起发送提升性能;

UDP读写数据:

#include < sys/types.h >
#include < sys/socket.h >

ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *addr, socklen_t *addrlen);
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *addr, socklen_t *addrlen);

由于UDP是无连接的,所以不需要connect或者accept直接填addr地址发送或者接收数据。

10、获取地址信息

#include < sys/socket.h >

int getsockname(int sock, const struct sockaddr *addr, socklen_t *addrlen); 
int getpeername(int sock, const struct sockaddr *addr, socklen_t *addrlen);

(1)getsockname通过fd获取【本端】的socket地址;
(2)getpeername通过fd获取【对端】的socket地址;

11、一些socket选项

(1)SO_REUSEADDR强制处于TIME_WAIT状态的socket句柄可以被bind;
(2)SO_RECVBUFSO_SENDBUF设置socket句柄的发送缓冲区和接收缓冲区的大小;
(3)SO_RECVLOWATSO_SNDLOWAT设置句柄在缓冲区触发I/O事件的大小,接收低潮限度和发送低潮限度默认为1字节;(4)SO_LINGER用于控制close系统调用在关闭TCP连接时的行为,其结构体:

#include < sys/socket.h >
struct linger
{
    int l_onoff; // 开启(非0)还是关闭(0)该选项
    int l_linger; // 滞留时间
};

// 1、l_onoff等于0(关闭),此时SO_LINGER选项不起作用,close用默认行为来关闭socket;
// 2、l_onoff不为0(开启),l_linger等于0,此时close系统调用立即返回,TCP模块将丢弃被关闭的socket对应的TCP发送缓冲区中残留的数据,同时给对方发送一个复位报文段(RST);
// 3、l_onoff不为0(开启),l_linger大于0,此时close的行为取决于两个条件:一是被关闭的socket对应的TCP发送缓冲区是否还有残留的数据;二是该socket是阻塞的,还是非阻塞的,对于阻塞的socketclose将等待一段长为l_linger的时间,直到TCP模块发送完所有残留数据并得到对方的确认;如果这段时间内TCP模块没有发送完残留数据并得到对方的确认,那么close系统调用将返回-1并设置errno为EWOULDBLOCK;如果socket是非阻塞的,close将立即返回,此时我们需要根据其返回值和errno来判断残留数据是否已经发送完毕;

第二部分:I/O函数

1、pipe

pipe作为IPC的一部分,其参数如下:

#include < unistd.h >

int pipe(int fd[2]);

通过fd[0]和fd[1]组成了管道的两端,fd[0]只能读出数据,fd[1]只能写入数据,配合readwrite使用,当然管道的容量是有限制的(默认是65536字节),可以通过fnctl修改大小。

2、socketpair

对比管道,我觉得socketpair更加方便,其参数如下:

#include< sys/types.h >
#include< sys/socket.h >
int socketpair(int domain, int type, int protocol, int fd[2]);

其中fd[2]和pipe一样,不同的是可以读也可以写,domain参数设置AF_UNIX

3、dup和dup2

#include< unistd.h >
int dup(int oldfd);
int dup2(int oldfd, int newfd);

dup函数创建一个新的文件描述符,该新文件描述符和原有文件描述符oldfd指向相同的文件、管道或者网络连接。并且dup返回的文件描述符总是取系统当前可用的最小整数值;
dup2dup类似,不过它将返回第一个不小于newfd的整数值的文件描述符,并且newfd这个文件描述符也将会指向oldfd指向的文件,原来的newfd指向的文件将会被关闭(除非newfdoldfd相同),相比于dup函数,dup2函数它的优势就是可以指定新的文件描述符的大小,用法比较灵活;

样例如下:

#include < stdio.h >
#include < sys/types.h >
#include < sys/stat.h >
#include < fcntl.h >
#include < unistd.h >

#define FILENAME "test.txt"
int main(void) {
    int fd1 = -1, fd2 = -1;
    fd1 = open(FILENAME, O_RDWR | O_CREAT | O_TRUNC, 0644);
    if (fd1 < 0)
    {
        return -1;
    }
    printf("fd1 = %d.n", fd1);
    fd2 = dup2(fd1, 10);
    printf("fd2 = %d.n", fd2); 
    close(fd1);
    return 0;
}

// 输出
fd2 = 10

4、readv和writev

#include < sys/uio.h >

ssize_t readv(int fd, const struct iovec *iov, int iovcnt);
ssize_t writev(int fd, const struct iovec *iov, int iovcnt);

struct iovec {                   /* Scatter/gather array items */
   void  *iov_base;              /* Starting address */
   size_t iov_len;               /* Number of Bytes to transfer */
};

fd被操作的目标文件描述符,iov是iovec类型的数组,iovcnt是iov数组的长度,iovec结构体封装了一块内存的起始位置和长度。
readvwritev的目的将分散的内存数据集中读写到文件描述符中,可以提升性能。

writev样例如下:

...
char *str0 = "this is 0 ";
char *str1 = "this is 1";
struct iovec iov[2];
ssize_t nwritten;

iov[0].iov_base = str0;
iov[0].iov_len = strlen(str0);
iov[1].iov_base = str1;
iov[1].iov_len = strlen(str1);

nwritten = writev(STDOUT_FILENO, iov, sizeof(iov));
...

readv样例如下:

...
char buf1[8] = { 0 };
char buf2[8] = { 0 };
struct iovec iov[2];
ssize_t nread;

iov[0].iov_base = buf1;
iov[0].iov_len = sizeof(buf1) - 1;
iov[1].iov_base = buf2;
iov[1].iov_len = sizeof(buf2) - 1;

nread = readv(STDIN_FILENO, iov, 2);
...

5、sendfile

通常对于文件的读写然后发送出去,会经过磁盘->内核态拷贝->用户态read->用户态write->内核态拷贝->DMA,那么这里经过多次上下文切换和拷贝,所以sendfile系统函数为了避免这些问题,实现零拷贝。

图片

#include < sys/sendfile.h >

ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

(1)out_fd待读出的文件fd,必须是一个socket句柄;
(2)in_fd待写入的文件fd,必须是文件描述符,不能是管道或者socket句柄;

6、splice

splice用于在两个文件描述符之间移动数据,也是一种重要零拷贝技术。

#include < fcntl.h >

ssize_t splice(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out, size_t len, unsigned int flags);

(1)fd_in待输入数据的文件描述符,如果fd_in是一个管道文件,那么off_in必须被设置为NULL;如果不是,那么off_in表示从输入数据流的何处开始读取数据,此时,若off_in被设置为NULL,则表示从输入数据流的当前偏移位置读入;若off_in不为NULL,则将指出具体的偏移位置;
(2)fd_out/off_out参数含义与fd_in/off_in相同,不过用于输出流;

【限时免费】一键获取网络规划系统模板+传输架构设计+连通性评估方案

相关文章

服务电话:
400-035-6699
企服商城