ntytcp协议栈(零拷贝&柔性数组)

ntytcp协议栈(零拷贝&柔性数组)

前言

最近阿光在阅读一些优秀前辈的源码时,了解到了一套国产化的tcp/ip协议栈—ntytcp协议栈,该协议栈由G站ID: wangbojing 先生开源。它是一套单线程用户态协议栈,使用epoll实现高并发,据说可以达到C10K的性能,不知道是真是假,抱着学习的态度,我们来了解一下它高性能的原因之一:零拷贝技术。以及介绍一种网络协议栈中常用的柔性数组。

零拷贝

背景

我们以linux操作系统的标准I/O接口是基于数据拷贝操作的。I/O操作会导致数据在内核空间和用户空间之间进行传输。这样做可以减少磁盘I/O操作。但是存在的一个问题就是频繁的数据拷贝会有很大的CPU开销,限制导致操作系统传输数据效率比较的低下,尤其是在并发量很大的情况下,低下的数据传输效率将导致很大的系统性延时,造成性能降低。

零拷贝技术在并发量越来越大的情况下应运而生,他完美的解决了高并发下数据传输效率的问题。在某种程度上减少甚至避免了不必要的CPU数据拷贝的操作。

概念

它是一种避免CPU进行数据拷贝的技术,可以减少用户空间和内核空间之间由于上下文切换而带来的开销;数据拷贝是一件简单而机械的任务,CPU一直被占用着去做数据拷贝是一件很浪费资源的事情。那么零拷贝技术的目标可以概括如下:

1.避免数据拷贝

避免内核缓冲区之间的数据拷贝

避免内核空间与用户空间之间的内存拷贝

用户程序可以避开操作系统直接访问硬件外设

数据传输尽量让DMA来做

2.综合目标

避免不必要的系统调用和上下文切换

需要拷贝到的数据可以先缓存起来

数据处理尽量交给硬件来做

我们来看一个传统的IO执行的流程:对于服务器来说我们下载的过程即将主机磁盘文件通过已经连接的socket发出去:核心代码如下:


while((n = read(diskfd, buf, BUF_SIZE)) > 0)
    write(sockfd, buf , n);

传统的IO流程包括read和write的过程

大致的流程如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Udu6qWRI-1666426811413)(https://pic1.imgdb.cn/item/6353a2a916f2c2beb18a90e9.jpg)]

1.应用程序调用read函数,向操作系统发起IO调用,上下文从用户态切换到内核态。

2.DMA控制器把数据从磁盘读取到内核缓冲区

3.CPU把内核缓冲区数据拷贝到用户应用缓冲区,上下文从内核态切换到用户态,这个时候read函数返回

4.用户应用程序通过write函数,发起IO调用,上下文从用户态切换到内核态

5.CPU将缓冲区的数据拷贝到SOCKET缓冲区

6.DMA控制器将数据从socket拷贝到网卡设备,上下文从内核态切换到用户态,write返回。

从上面可以看出,传统的IO流程包括了4次上下文的切换,4次数据拷贝

其中内核空间主要提供进程调度,内存分配,连接硬件资源等功能;用户空间,提供给各个程序进程的空间,它不具有访问内核空间资源的权限,可以通过系统调用来完成用户空间的切换。

CPU上下文:CPU寄存器,是CPU内置的容量小,但速度极快的内存。而程序计数器,则是用来查询CPU正在执行的指令位置,或者即将执行的下一条指令的位置。他们都是CPU在运行任何任务之前,必须要依赖的环境,因此叫做CPU上下文。

虚拟内存

在现代操作系统中,虚拟内存取代了物理地址,关于虚拟内存的内容阿光在之前的章节里面也有提到过,使用虚拟内存有如下两个好处:

1.虚拟内存空间远大于物理地址空间

2.多个虚拟内存可以指向同一物理地址

那么正是由于这种机制使得0拷贝的实现有了可能:

DMA技术

DMA即直接内存访问,他允许外部设备和内存直接进行IO数据传输,这个过程不需要CPU的参与

简单的说他就是帮助CPU转发一下IO请求以及拷贝数据。这时候CPU资源就能被释放出来去做别的事情,提高CPU的利用效率。下面我们通过一张图来看下DMA都做了那些工作:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-WcxjREgB-1666426811414)(https://pic1.imgdb.cn/item/6353a2e216f2c2beb18aed8d.jpg)]

可以从上图看出来DMA方式区别于中断方式,直接绕过CPU请求将数据拷贝到主存,减少CPU资源消耗,硬件效率可以大大提高!

方式

那么很明显我们要继续优化上述流程的话,只需要做到两点即可:

  • 1.减少CPU上下文切换的次数
  • 2.减少内存拷贝次数

零拷贝技术主要有以下两种实现方式:

mmap+write方式

senndfile方式

mmap+write

mmap 即 memeoy map 内存映射机制,我们使用mmap()来代替前面IO流程中的read(),mmap()会将内核缓冲区的数据直接映射到用户空间,该过程由DMA完成,在调用write()将数据搬运到socket缓冲区,最后socket将数据交付给网卡,整个过程减少了一次由内核到用户态的数据拷贝。

存在问题及解决方案

需要注意的是,在多线程模式下,如果我们mmap了一个文件,但这个时候文件被另一个线程处理或者截断了,那么我们调用write的时候就会因为访问非法地址而导致进程被CPU杀死,导致程序崩溃!所以这就告诉我们在对mmap的文件进行操作的时候一定要加上一个锁!在操作完之后再解锁。

加锁的操作可以保证当前进程被CPU杀死之前由一个信号通知当前进程将write()返回当前写入的长度,将erron返回SUCC

sendfile

Linux从2.1内核版本开始就支持一个用于传输文件的系统调用接口sendfile(),该系统调用可以替代read()和write()完成一次系统调用;另外,该系统调用可以直接将内核缓冲区的数据拷贝到socket缓冲区,减少了中间用户态的一次拷贝。通过以上sendfile()就减少了整个过程中2次数据拷贝和3次的CPU上下文切换,但这些也没有实现真正意义上的0拷贝。

随着版本的迭代,在Linux 2.4内核版本到来的时候,sendfile()过程发生了如下的变化:

通过DMA将磁盘数据直接拷贝到内核缓冲区中

只将缓冲区描述符和长度拷贝到socket缓冲区,而缓冲区数据可以通过网卡的SG-DMA控制器直接拷贝到网卡缓冲区中,这样就又减少了一次数据拷贝。

该过程中全程没有CPU参与数据拷贝,都是有DMA完成,可以算是真正意义上的零拷贝技术了。

柔性数组

之前在阅读ntytcp协议栈的时候发现用以数据传输的结构体中定义了一个char data[0] 这个东西我们平时编程中很少使用到,带着一些不解对这种数据结构做了一些了解:

C99标准引入了柔性数组这一特性,这个柔性数组的大小可以根据程序运行中需要的大小进行更改。需要注意的是,柔性数组只能在结构体的最后一个成员进行定义,且该结构体必须最少包含一个其他数据类型的成员。例如:

typedef struct data_struct{
  int msg_length;
  int msg_id;
  char msg[0];
}data_struct;

柔性数组是不占用结构体的内存,也就是说sizeof(struct)不会计算柔性数组,如下:

柔性数组测试

可以看见在计算结构体占用内存的时候只计算了前两个int类型数据,并没有计算数组。

至于它为什么不占用内存,感兴趣的小伙伴可以尝试将代码汇编一下,对比一下指针数组和柔性数组:

f.a->s
30c30/36c36
<   addq    $4, %rax
---
>   movq    8(%rax), %rax

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-WdmecSbC-1666426811415)(https://pic1.imgdb.cn/item/6353a32016f2c2beb18b69c8.jpg)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wX3zyylL-1666426811415)(https://pic1.imgdb.cn/item/6353a33416f2c2beb18b8a74.jpg)]

addq这一步操作翻译过来: %rax+sizeof(struct) 该结构体的末尾正好是柔性数组,由于柔性数组只代表数据的其实地址,所以这一步只拿到了地址;但是movq 则是直接操作地址里面的内容。

那么就可得,我们访问柔性数组就是在访问数组的相对地址,但是访问指针数组就是在访问相对地址里的内容。

好了我们现在假设一个场景,现在服务器要下发一包不知道多大的数据给到客户端,现在关于数据接受有三种方案:

采用定长数组缓存数据

#define LENGTH_MAX 1024
typedef struct data_struct
{
	int data_length;
	int data_msg;
	char data[LENGTH_MAX];
}data_struct;

采用指针数组缓存数据

typedef struct data_struct
{
	int data_length;
	int data_msg;
	char  *data;
}data_struct;

采用柔性数组缓存数据

typedef struct data_struct
{
	int data_length;
	int data_msg;
  char data[0];
}data_struct;

下面我们来看一下三个存储方式的优劣:

首先第一种情况:客户看了想打人系列,用一个定长数组去存储数据,每一次下行数据无论大小都有占用固定大小的内存来存储数据,会造成设备内存的浪费。因此这种结构在实际的开发中一般很少用到。

第二种指针数组存放:这是目前使用最普遍的一种数据类型,内存大小可以随意申请,使用完释放即可,比较节约空间;但是存在的一个问题:这种方法需要首先对结构体申请内存,其次对结构体数据域申请内存,也就是两次不连续的内存申请,这对程序猿来说是很不友好的,意味着我们需要进行两次内存释放。由于多数情况下申请的内存不会在同一个接口里面进行释放,这很容易导致内存泄漏。

经典案例就是我司某款产品在客户现场由于云端下发数据帧申请的内存在另一个线程数据入队后没有及时释放,导致和云端进行6000+次通信后内存耗尽的情况,可谓损失惨重!

第三种柔性数组存放:相较于第二种方法,使用柔性数组可以一次性申请任意大小的内存用于存放数据,注意是一次性,也就意味着我们可以一次性释放内存,这无疑大大减少了内存泄漏的风险:

typedef struct data_struct
{
	int data_length;
	int data_msg;
  char  data[0];
}data_struct;
data_struct * test_buf = NULL; 
    ///  开辟
if ((test_buf = (struct data_struct *)malloc(sizeof(struct data_struct) + sizeof(char) * CURR_LENGTH)) != NULL)
{
    test_buf->len = CURR_LENGTH;
    memcpy(test_buf->data, "Hello World", CURR_LENGTH);
    printf("%d, %s\n", zbuffer->len, zbuffer->data);
}
free(test_buf); //一次释放即可 
test_buf = NULL;

总结一下,使用柔性数组进行数据缓存的优点:

  • 1.相比于定长数组来说节省空间和网络带宽
  • 2.相较于指针数组来说数组本身不占用空间,且可以一次性申请内存和释放内存,降低了内存泄漏的风险,同时也降低了内存碎片产生的概率。

欢迎来公众号看看呀: 学习学个der

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值