常识一:
数据是存储在磁盘的。
那么衡量磁盘的两个指标分别是寻址时间(ms)和带宽(G/M)。
而内存的寻址时间是ns级的, 就单单寻址时间这一方面,内存就和磁盘差了将近10万倍。
内存的带宽也很大(直接走的CPU的总线),宽带也远大于磁盘。
常识二 I/O buffer :
磁盘有磁道和扇区,一扇区 512Byte,我们寻址的基本单位越小,就会来带一个成本变大(索引变多),如果我们访问磁盘中的数据都是以一个最小粒度为512Byte来找,会使得我们查找数据的成本变大,因此我们不会以一个扇区作为最小粒度,我们使用4K作为最小粒度,就会使得索引变小,减少成本。并且如果文件很大,一次性读入4K的数据,也会减少传输的次数。
因此无论你读多少,操作系统都是以最少4K从磁盘拿数据。
常识三 :
关系型数据库建表的时候,必须给出schema,即必须给出这个表有多少列的类型分别是什么,类型本质上就是字节宽度,这样的话,这个表每一行的宽度,就被定死了,那么未来我定义一张表,这个表有十个字段,即有十列,但是我只在第1列和第7列有数据,其他列我用0去占位。,数据库偏向于行级存储,这样做有什么好处,未来的增删改不用移动数据。
我们在文件里存放一个data.txt,我们无论是用Linux的grep命令、awk命令亦或是编写一个程序来寻找个文件。
为什么随着文件的变大,速度会变慢?磁盘I/O成为瓶颈,这是目前计算机不可逾越的。
数据库根据以上的常识,出现了dataPage,大小为4K,即一张表用了很多很多4K的小格子,因为即使我的表为1k,内存从硬盘读的时候依然是4K的读取,索性我就直接以4K作为最小单位,但是定义为8K无所谓,只有定义的比4K小才会有浪费,但是这样还是很慢,因为还是要直接从磁盘中读入一个个4K。
建立索引来提高速度,即一张或多张dataPage中专门用来存放索引,随着数据变大,索引也会变多,根本上说,索引也是一种特殊的数据。
数据和索引都是存储在磁盘中,我们再在内存中准备一个B+树,树干在内存中,叶子是datapage,在磁盘中,where条件,根据树干,找到对应的叶子,将对应的叶子读入内存,找到目标记录,充分利用各自的优势,利用了磁盘的大量的存储空间,内存速度快,而且有数据结构加快我们的查找速度,目的:减少I/O。
随着数据量的变大,一张表本身占据很大的行,即几百万或者几千万条的数据,性能一定会变低,为什么?
如果表有索引,增删改变慢,因为要修改数据的话,就要修改索引,调整位置,维护索引会有时间开销,查询速度呢?
1、一个或少量查询,依然很快(获取一个dataPage)
2、假设B+不会受到影响,即内存可以将b+树的树干存住,并发大的时候会受到硬盘带宽影响速度,即会从磁盘读取不同的dataPage,需要排队,从磁盘读入内存。
因此引出一个问题,如果使用磁盘数据库,就会有速度慢,性能低,但是如果使用纯内存的数据库,成本又过高。采用一个折中方案 缓存(memcached,redis)
从计算机发展到现在,两个基础设施:1、冯诺依曼体系硬件的制约。2、以太网 TCP/IP网络(不稳定)
redis是key-value的类型
memcached出现早于redis,为什么redis可以取代memcached呢
memcached和redis最本质的区别,memcached的value没有类型的概念,这很容易让人想到json,json既然可以表示很复杂的数据结构,那么是不是value有没有类型的概念也就无所谓了呢,因此,value有了类型有什么优势?
当一个客户端 ,想从一个(k-v)缓存系统取回一个value中的某一个元素
memcached是怎么做的,返回value中的所有数据返回给客户端,这样做的话,网卡I/O是瓶颈,且客户端要编码来解析这个json数据。
redis是如何做的,类型不是很重要,重要的是redis的service对每种类型都有自己的方法,客户端可以通过调用redis实现的方法,直接从value中取得对应的元素,这样实现了计算向数据移动,使得可以取得少量数据,减少了网卡的负担,且客户端的代码比较轻盈,解耦合。
插曲:
BIO:
一个服务器的kernel,可以接收很多客户端的连接,连接肯定是先到达内核。一个连接就是一个文件描述符fd,很多个连接肯定有很多个文件描述符。
最早的时候,进程通过调用内核的read命令来读取文件描述符,进程1要读fd8,进程2要读fd9,因为socket在这个时期是blocking的,阻塞的,即当进程1要读fd8的时候,fd8还没有到达内核,还在传输,进程二就得等进程一完成后才可以执行,形成阻塞,有数据就处理,没数据阻塞着,后边的数据无法执行,虽然数据已经达到了。
同一个时间点就只有一个I/O得到处理,CPU并没有时刻在处理那些已经到达的数据,造成资源浪费,且如果线程很多的话,线程的切换也是有成本的。
一旦接收到一个连接请求,就可以建立通信套接字在这个通信套接字上进行读写操作,此时不能再接收其他客户端连接请求,只能等待同当前连接的客户端的操作执行完成。
如果BIO要能够同时处理多个客户端请求,就必须使用多线程,即每次阻塞等待来自客户端请求,一旦受到连接请求就建立通信套接字同时开启一个新的线程来处理这个套接字的数据读写请求,然后立刻又继续accept等待其他客户端连接请求,即为每一个客户端连接请求都创建一个线程来单独处理。这就是BIO。
这样造成了很多的资源浪费,这样的话,计算机硬件很难被利用起来,内核要发生改变。
为什么抛出很多线程不好,JVM:一个线程的的成本:默认1MB
一、线程多了,调度成本CPU浪费
二、内存成本
同步非阻塞忙轮询:
我只使用一个线程,我在这个线程里写一个while死循环,我要读fd8,fd9和fd10,我先读fd8,如果没有,我就去读fd9,而不是和BIO一样,等待fd8的到达,如果fd9有,我就开始处理fd9,再去fd10,fd8 =====》轮询。
那么此时,遇到的问题是什么,如果有1000个fd,代表用户进程轮询要用1000次kernel,成本很大,即用户查询一个fd,就要进行因此系统调用,用户态和内核态进行切换,但是这1000次个fd,并不是一下就准备好了,还有很多没有准备好,很多时候返回的是没有准备好,没有进行实际的处理,CPU很大的资源浪费在资源的判断上。
多路复用的NIO,select:
线程一次性将我要调用的1000fd传递给内核,让内核去监控这1000个fd,此时若有fd到达,则会告诉线程,有fd到了,但是并不明确的说是哪个到了,线程还要遍历这1000个fd,查看是哪个到了,再对到了的进行处理
每次调用 select 都需要从用户空间把描述符集合拷贝到内核空间,当描述符集合变大之后,用户空间和内核空间的内存拷贝会导致效率低下,而且select每次需要线性遍历所有socket,以确定是哪个或者哪几个 socket 就绪了。
epoll 的几个特点
- 程序在内核空间开辟一块缓存,用来管理 epoll 红黑树,高效添加和删除
- 红黑树位于内核空间,用来直接管理 socket,减少和用户态的交互
- 使用双向链表缓存就绪的 socket,数量较少
- 只需要拷贝这个双向链表到用户空间,再遍历就行,注意这里也需要拷贝,没有共享内存
int epoll_create(int size);
创建一个 epoll instance,实际上是创建了一个 eventpoll 实例,包含了红黑树以及一个双向链表,这个 eventpoll 实例是直接位于内核空间的。红黑树的叶子节点都是 epitem 结构体。函数的返回值是红黑树的根节点。
当往这棵红黑树上添加、删除、修改节点的时候,我们从(用户态)程序代码中能操作的是一个 fd,即一个 socket 对应的 file descriptor,所以一个 epitem 实例与一个 socket fd 一一对应。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
往 epoll instance 上添加、删除、更改一个节点(socket), 第一个参数即要操作的红黑树的根节点,即epoll_create函数的返回值。 op表示具体的操作,EPOLL_CTL_ADD(注册新的fd到epfd) EPOLL_CTL_MOD(修改已经注册到epfd的监听事件) EPOLL_CTL_DEL(删除某个fd)。 fd:需要监听的文件描述符。 epoll_event需要监听的事件。
EPOLLIN 读,EPOLLOUT 写
例子:
触发事件:EPOLLIN 读 EPOLLOUT 写,即如果这个io可读或可写,这个事件就要被激活。
epfd:指定要监听的集合
返回值为有多少个文件描述符准备好了,且将准备就绪的事件,放入my_event中 (内核将rdlist拷贝到my_event中),然后CPU就可以遍历这个数组,遍历长度为wait函数的返回值,然后从数组中的每个event中得知fd的信息,再对fd进行操作。
epoll 的触发
当我们往 epoll 红黑树上添加一个 epitem 节点(也就是一个 socket 对象,或者说一个 fd)后,实际上还会在这个 socket 对象的 wait queue 上注册一个 callback function,当这个 socket 上有事件发生后就会调用这个 callback function,简单讲就是,这个 socket 在添加到这棵 epoll 树上时,会在这个 socket 的 wait queue 里注册一个回调函数,当有事件发生的时候再调用这个回调函数(而不是唤醒进程)。
那么这个回调函数做了什么事呢?
很简单,这个回调函数会把这个 socket 添加到创建 epoll instance 时对应的 eventpoll 实例中的就绪链表上,也就是 rdllist 上,并唤醒 epoll_wait,通知 epoll 有 socket 就绪,并且已经放到了就绪链表中,然后应用层就会来遍历这个就绪链表,并拷贝到用户空间,开始后续的事件处理(read/write)。
所以这里其实就体现出与 select 的不同, epoll 把就绪的 socket 给缓存了下来,放到一个双向链表中,这样当唤醒进程后,进程就知道哪些 socket 就绪了,而 select 是进程被唤醒后只知道有 socket 就绪,但是不知道哪些 socket 就绪,所以 select 需要遍历所有的 socket。
另外,应用程序遍历这个就绪链表,由于就绪链表是位于内核空间,所以需要拷贝到用户空间,这里要注意一下,网上很多不靠谱的文章说用了共享内存,其实不是。由于这个就绪链表的数量是相对较少的,所以由内核拷贝这个就绪链表到用户空间,这个效率是较高的。
我来来直接看一下 epoll_wait 做了什么事?epoll_wait 最终会调用到 ep_send_events_proc 这个函数,从函数名字也知道,这个函数是用来把就绪链表中的内容复制到用户空间,向应用程序通知事件。
总体来看:
- epoll 在内核开辟了一块缓存,用来创建 eventpoll 对象,并返回一个 file descriptor 代表 epoll instance
- 这个 epoll instance 中创建了一颗红黑树以及一个就绪的双向链表(当然还有其他的成员)
- 红黑树用来缓存所有的 socket,支持 O(log(n)) 的插入和查找,减少后续与用户空间的交互
- socket 就绪后,会回调一个回调函数(添加到 epoll instance 上时注册到 socket 的)
- 这个回调函数会把这个 socket 放到就绪链表,并唤醒 epoll_wait
- 应用程序拷贝就绪 socket 到用户空间,开始遍历处理就绪的 socket
- 如果有新的 socket,再添加到 epoll 红黑树上,重复这个过程。
零拷贝sendfile:
假设我们有一个网卡(socketI/O),还有一个文件(文件I/O),我需要将文件中的数据写入网卡中,通过网卡传输出去,那么我需要将数据先从内核的缓冲区,通过read读入用户空间,再用write()写入内核,再通过内核传出去,过程是要拷贝的。
1:调用read函数,文件数据copy到内核缓冲区
2:read函数返回,文件数据从内核缓冲区copy到用户缓冲区
3:write函数调用,将文件数据从用户缓冲区copy到内核与socket相关的缓冲区
4:数据从socket缓冲区copy到相关协议引擎。
发生了4次拷贝操作,
ssize_t senfile(int out_fd,int in_fd,off_t* offset,size_t count);
in_fd参数是待读出内容的文件描述符,out_fd参数是待写入内容的文件描述符。offset参数指定从读入文件流的哪个位置开始读,如果为空,则使用读入文件流默认的起始位置。count参数指定文件描述符in_fd和out_fd之间传输的字节数。
sendfile系统调用导致文件内容通过DMA模块(DMA是一种无需CPU的参与就可以让外设和系统内存之间进行双向数据传输的硬件机制)被复制到内核缓冲区中,只有记录数据位置和长度的描述符被加入到socket缓冲区中。DMA模块将数据直接从内核缓冲区传递给协议引擎。
kafka