一、C10M问题
1.1 C10K
在讲解C10M问题之前,我们先回顾C10K的问题。
10年前,开发人员处理C10K可扩展性问题时,尽量避免服务处理器超过1万个的并发连接。通过改进操作系统内核以及用事件驱动服务器(典型技术实现如nginx和Node)代替线程服务器(典型代表:Apache),使得这个问题已经被解决。
以传统的网络编程模型作为代表的Apache为例,C10K问题上的局限表现在:Apache的问题在于服务器的性能会随着连接数的增多而变差。
性能和扩展性是不是一回事?
答案是否定的。
比如Apache,持续几秒的短期连接,比如快速事务,如果每秒处理1000个事务,只能约1000个并发连接到服务器。如果事务延长到10秒,要维持每秒1000个事务则必须打开10000个并发连接。这种情况下,即使不顾DOS攻击,Apache性能也会性能陡降,同时大量的下载操作也会使Apache崩溃。
如果每秒处理的连接从5千到1万,如果升级硬件并提高处理器速度到原理的2倍,我们得到了2倍的性能,但没有得到两倍的处理规模。每秒处理连接可能只达到了6000,如果继续提高速度,情况也没有改善。甚至16倍的性能时,仍然不能处理1万个并发连接。因此,性能和可扩展性是不一样的。
问题在于Apache会创建一个CGI进程,然后关闭,这个步骤并没有扩展。内核使用的O(N^2)算法使处理器无法处理1万个并发连接。
OS 内核中的两个基本问题 :
连接数=线程数/进程数:当一个数据包进来,内核会遍历其所有进程以决定由哪个进程来处理这个数据包。
连接数=选择数/轮询次数(单线程):同样的可扩展性问题,每个包都要走一遭列表上所有的 socket。
针对Apache表现出来的问题,彻底解决并发性能问题的解决方法的根本是改进OS内核使其在常数时间内查找,使线程切换时间与线程数量无关,使用一个新的可扩展epoll()/IO completion Port常数时间去做socket查询。
1.2 C10M
实现C10M(1千万)并发连接意味着什么?
1千万的并发连接数。
100万个连接/秒:每个连接以这个速度保持约10秒;
10GB/秒的连接:快速连接到互联网;
1千万个数据包/秒:据估计目前的服务器每秒处理 50K 数据包,以后会更多;
10 微秒的延迟: 可扩展服务器也许可以处理这个规模(但延迟可能会飙升);
10 微秒的抖动: 限制最大延迟;
并发 10 核技术: 软件应支持更多核的服务器(通常情况下,软件能轻松扩展到四核,服务器可以扩展到更多核,因此需要重写软件,以支持更多核的服务器)。
解决C10M问题的一些思路:
将功能逻辑做好恰当的划分:数据面专门负责数据的处理,属于资源消耗的主要因素;控制面只负责一些偶尔才有非业务逻辑,比如与外部用户的交互、信息统计等。
另外,归纳一些高性能的网络数据处理框架( Intel 的 DPDK、6wind、windriver),我们发现有如下共同点可以参考:
数据包直接传递到业务逻辑,不经过linux内核协议栈。
多线程的核间绑定:每个线程绑定到一个处理核心,好处是最大化核心CACHE利用、实现无锁设计、避免进程切换消耗等。
内存是另外一个核心要素:常见的内存池设计必须得以切实应用。
同时,我们不能单纯从软件上去思考解决方案,而是需要结合操作系统,比如:内存、CPU、磁盘、网卡、应用程序、操作系统。
二、用户态协议栈设计
我们来看下,一般的客户端请求到应用程序的具体数据流程:
步骤:
1)客户端请求数据,先经过网卡,服务器需要从网卡copy数据到内核协议栈(tcp/bsd)。
2)再从内核协议栈copy数据到应用程序。
由此可见,客户端与应用程序之间的数据交互,多了两次数据拷贝的操作,在大量数据并发的情况下,必将会严重影响性能。
优化思路:可以跳过内核协议栈,去除拷贝操作,数据直接从网卡到应用程序,这种方式称为零拷贝。
mmap
mmap是一种内存映射文件的方法,即将一个文件或者其它对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系。
有了mmap的映射,应用程序进程就可以采用指针的方式读写操作这一段内存,而系统会自动回写脏页面到对应的文件磁盘上,即完成了对文件的操作而不必再调用read,write等系统调用函数。netmap开源框架就是采用这种方式实现的。
简单实现流程可表述如下图:
三、NETMAP
netmap是一个高性能收发原始数据包的框架,其包含了内核模块以及用户态库函数。
目标是:不修改现有操作系统软件以及不需要特殊硬件支持,实现用户态和网卡之间数据包的高性能传递。
原理如下图:数据包不经过操作系统内核进行处理,,用户空间程序收发数据包时,直接与网卡进行通信。
NIC:linux下的子系统,是网卡的一层软件封装,为了兼容不同厂家的网卡。
Eth0:NIC的实例化对象,参数跟物理网卡一一对应。
3.1 NETMAP数据结构
在netmap框架,内核拥有数据包池,发送环接收环上的数据包不需要动态申请,有数据到达网卡时,直接从数据包池中取出一个数据包,然后将数据放入此数据包中,再将数据包的描述符放入接收环中。内核中的数据包池,通过mmap技术映射到用户空间,用户态程序最终通过netmap_if获取接收发送环netmap_ring,进行数据包的获取发送。
3.2 NETMAP特点
性能高 :数据包不走传统协议栈,不需要层层解析,用户态直接与网卡的接收环和发送环交互。性能高的具体原因有:
系统调用以及处理数据包的时间花费少。
不需要进行数据包的内存分配:采用数据包池,当有数据到达时,直接从数据包池中取出一个数据包,然后将数据放入此数据包中,再将数据包的描述符放入接收环中。
数据拷贝次数少:内核中的数据包采用mmap技术映射到用户态。所以数据包在到达用户态时,不需要进行数据包的拷贝。
稳定性高 :有关网卡寄存器数据的维护都是在内核模块进行,用户不会直接操作寄存器。所以在用户态操作时,不会导致操作系统崩溃。
亲和性 :采用了CPU亲和性,实现CPU和网卡绑定,提高性能。
易用性好 :API操作简单,用户态只需要调用ioctl函数即可完成数据包收发工作。
与硬件解耦 :不依赖硬件,只需要对网卡驱动程序稍微做点修改就可以使用此框架,传统网卡驱动将数据包传递给操作系统内核协议栈,而修改后的数据包直接放入netmap_ring供用户使用。
3.3 NETMAP API
netmap API函数主要为2个头文件,netmap.h和netmap_user.h,当解压下载好的netmap程序后,在./netmap/sys/net/目录下,文本主要对这2个头文件进行分析。
1、netmap.h 头文件
netmap.h 被 netmap_user.h 调用,里面定义了一些宏和几个主要的结构体,如nmreq{}, netmap_if{}, netmap_ring{}, netmap_slot{}。
一个网卡(或者网络接口)只有一个 netmap_if{}结构,在使用 mmap()申请的共享内存中,通过 netmap_if{}结构可以访问到任何一个发送/接收环(也就是 netmap_ring{}结构,一个netmap_if{}可以对应多发送/接收环,这应该和物理硬件有关。
找到 netmap_ring{}的地址后,我们就可以找到环中每一个 buffer 的地址(buffer 里面存储的是将要发送/接收的数据包)。
通过一个 nifp 是如何访问到多个收/发环的,通过一个 ring 如何找到多个不同的 buffer地址的,其实都是通过存储这些结构体相邻的后面一部分空间实现。
2、宏定义
1)_NETMAP_OFFSET
#define _NETMAP_OFFSET(type, ptr, offset) \
((type)(void *)((char *)(ptr) + (offset)))
该宏将ptr指针(强转成char*类型)向右偏移offset个字节,再将其转化为指定的类型type。
2)NETMAP_IF
#define NETMAP_IF(_base, _ofs) _NETMAP_OFFSET(struct netmap_if *,
_base, _ofs)
该宏将_base 指针向右偏移_ofs 个字节后,强转为 netmap_if *类型返回。在 nemap中通过此宏得到 d->nifp 的地址。
3)NETMAP_TXRING
define NETMAP_TXRING(nifp, index) _NETMAP_OFFSET(struct netmap_ring
*, \
nifp, (nifp)->ring_ofs[index] )
通过该宏定义,可以找到 nifp 的第 index 个发送环的地址(index 是从 0 开始的),ring_ofs[index]为偏移量,由内核生成。
其中,我们注意到 struct netmap_if{}最后面只定义了 const ssize_t ring_ofs[0], 实际上其它的 netmap 环的偏移量都写在了该结构体后面的内存地址里面,直接访问就可以了。
4)NETMAP_RXRING
#define NETMAP_RXRING(nifp, index) _NETMAP_OFFSET(struct netmap_ring
*, \
nifp, (nifp)->ring_ofs[index + (nifp)->ni_tx_rings + 1] )
通过该宏定义,可以找到 nifp 的第 index 个接收环的地址,其中(nifp)->ring_ofs[]里面的下标为 index+(nifp)->ni_tx_rings+1,正好与发送环的偏移量区间隔开 1 个。
5)NETMAP_BUF
#define NETMAP_BUF(ring, index) \
((char *)(ring) + (ring)->buf_ofs +
((index)*(ring)->nr_buf_size))
通过该宏定义,可以找到 ring 这个环的第 index 个 buffer 的地址(buffer 里面存的就是我们接收/将发送的完整数据包),每个 buffer 占的长度是 2048 字节(在(ring)->nr_buf_size也给出了)。
其中(ring) ->buf_ofs 是固定的偏移量,不同的环这个值不相同,但所有的(char*)(ring)+(ring)->buf_ofs 会指向同一个地址,也就是存放 buffer 的连续内存的开始地址(d->buf_start 会指向该地址)。
6)NETMAP_BUF_IDX
#define NETMAP_BUF_IDX(ring, buf) \
( ((char *)(buf) - ((char *)(ring) + (ring)->buf_ofs) ) / \
(ring)->nr_buf_size )
在讲 NETMAP_BUF 的时候我们说(char *)(ring) + (ring)->buf_ofs)总会指向存放 buffer 的起始位置 (无论是哪一个环 ),在这段内存中将第一个 buffer 下标标记为 0 的话,NETMAP_BUF_IDX 计算的恰好是指针 buf 所指 buffer 的下标。
3、nm_open函数
static struct nm_desc *nm_open(const char *ifname, const struct nmreq *req,uint64_t new_flags, const struct nm_desc *arg)
1)调用nm_open函数会对传递的ifname指针里面的字符串进行分析,提取出网络接口名。
如:nmr = nm_open("netmap:eth0", NULL, 0, NULL)
2)nm_open() 会 对 struct nm_desc *d 申 请 内 存 空 间 , 并 通 过 d->fd =open(NETMAP_DEVICE_NAME, O_RDWR);打开一个特殊的设备/dev/netmap 来创建文件描述符 d->fd。
3)通过 ioctl(d->fd, NIOCREGIF, &d->req)语句,将 d->fd 绑定到一个特殊的接口,并对 d->req结构体里面的成员做初始化,包括:
在共享内存区域中 nifp 的偏移,
共享区域的大小nr_memsize,
tx/rx 环的大小 nr_tx_slots/nr_rx_slots(大小为 256),
tx/rx 环的数量 nr_tx_rings、nr_rx_rings(视硬件性能而定)等。
4)接着在 if ((!(new_flags & NM_OPEN_NO_MMAP) || parent) && nm_mmap(d, parent))语句中调用 nm_mmap 函数, 继续给 d 指针指向的内存赋值。
4、nm_mmap函数
static int nm_mmap(struct nm_desc *, const struct nm_desc *);
具体实现:
static int nm_mmap(struct nm_desc *d, const struct nm_desc *parent)
{
//XXX TODO: check if mmap is already done
if (IS_NETMAP_DESC(parent) && parent->mem &&
parent->req.nr_arg2 == d->req.nr_arg2) {
/* do not mmap, inherit from parent */
D("do not mmap, inherit from parent");
d->memsize = parent->memsize;
d->mem = parent->mem;
} else {
/* XXX TODO: check if memsize is too large (or there is overflow) */
d->memsize = d->req.nr_memsize; //将需要申请的内存大小赋值给d->memsize
d->mem = mmap(0, d->memsize, PROT_WRITE | PROT_READ, MAP_SHARED,
d->fd, 0); //申请共享内存
if (d->mem == MAP_FAILED) {
goto fail;
}
d->done_mmap = 1;
}
{
struct netmap_if *nifp = NETMAP_IF(d->mem, d->req.nr_offset); //通过 d->req.nr_offset 这个偏移量的到 nifp 的地址, NETMAP_IF 前面说过
struct netmap_ring *r = NETMAP_RXRING(nifp, d->first_rx_ring); ///对 nifp,找接收包的环 r,因为 index 为 0,所以省略了
if ((void *)r == (void *)nifp) {
/* the descriptor is open for TX only */
r = NETMAP_TXRING(nifp, d->first_tx_ring);
}
*(struct netmap_if **)(uintptr_t)&(d->nifp) = nifp;对d->nifp 赋值,虽然 d->nifp 使用 const 定义的,但对其取地址再强值类型转换后,依然可以对其指向的空间进行操作
*(struct netmap_ring **)(uintptr_t)&d->some_ring = r; //同理,对 d->some_ring 进行赋值,此处指向了第一个接受(rx)环。
*(void **)(uintptr_t)&d->buf_start = NETMAP_BUF(r, 0); //计算第一个 buffer 的地址,并存入 d->buf_start 指针中
*(void **)(uintptr_t)&d->buf_end =
(char *)d->mem + d->memsize; //计算共享区间的最后一个地址,赋值给 d->buf_end
}
return 0;
fail:
return EINVAL;
}
1)nifp 为申请的共享内存首地址 d->mem 向右偏移 d->req.nr_offset(该值在调用前面的ioctl()时得到)得到。并且一个网络接口(网卡)只对应一个 nifp。 (使用宏 NETMAP_IF 计算)
2)得到的 nifp 的地址,nifp 结构体里最后定义的 ring_ofs[0]以及接下来内存中的 ring_ofs[1],ring_ofs[2]…,这些内存中存储的是访问每一个环(tx or rx ring)的偏移量,通过这个偏移量我们可以得到每一个环的地址(使用宏 NETMAP_RXRING/NETMAP_TXRING 进行计算)。
3)得到每个收/发环的地址了, netmap_ring 结构体最后面有一个 struct netmap_slot slot[0];,通过 slot[0],后面内存的slot[1], slot[2], slot[3]…, 取出里面的偏移量就可以得到每一个 buffer(也叫数据包槽)的地址了(使用宏 NETMAP_BUF 计算得到)。 到这里, netmap 如何访问到内存槽中的每一个 buffer 的。
4)在 struct nm_desc 中, nifp, some_ring, buf_start, buf_end 等指针都定义为 const 的,但我们通过对其取地址再强转指针的方式去往这些指针指向的内存中赋值。
注:在 nm_mmap()中使用 mmap()申请共享的时候, 这些数据结构里数据的设计是内核模块就已写好了的,我们在这里其实是在做验证。
7、nm_nextpkt函数
未完,待续…