在开启sqlite wal功能的时候,会在db文件的同级目录下多出一个,与db同名的,后缀为-shm的文件,这个文件就是本文需要讨论的shared-memory(shm)。文章只讨论sqlite在os层中shm的实现,而不关注shm中存储的内容和数据结构。
Shm-共享内存
shm-shared memory 是在开启wal的时候被用来存储wal-index(这个不是本层考虑的)。所以在没有定义SQLITE_OMIT_WAL这个宏的时候,sqlite os层提供了shm的能力。
从暴露的接口上来开一共只有4个接口函数,分别是:
1 | int sqlite3OsShmMap(sqlite3_file *,int,int,int,void volatile **);//映射文件 |
暂先不看代码,需要先从mmap开始说起…
关于mmap
这也是在看源码的时候,发现知识盲区了…然后去查了大量关于mmap的资料。这样虽然效率低了一点,但是还是便于之后的代码理解的。
mmap其实之前也有遇到用过,它的两个特性印象比较深刻,一个是mmap是内存文件映射,可以用读写内存的方式读写文件内容,二是在应用crash的时候或者系统掉电的时候可以把内存中数据的flush到文件中。但这些似乎不太够…
首先看普通的文件读写:
在此之前,再了解一个页缓存,页缓存是内核的一个重要的磁盘高速缓存,以页为单位进行缓存。可以简单的把它理解为…memory cache。读数据的时候会优先的从页缓存中读,而写数据的时候,也是优先写入页缓存,并且不会立刻回写文件,而是标记dirty,延迟一段时间或者手动调用sync的时候才会回写。
这时候我们再看文件读写,发起一个read()或者write()系统调用,我们一般会分配一个buff缓冲区,然后会进入内核态,cpu会从页缓存中读取指定的文件缓存。如果发现页缓存中没有,那么这个时候cpu就会让出总线,用DMA的方式,将数据从磁盘拷贝到页缓存中,拷贝完成的时候处理DMA中断。在此之后,因为拷贝的地址是内核地址,cpu需要在内核态,再把页缓存拷贝至用户态的虚拟地址映射的物理地址即(buff对应的缓冲区)。
也就是说,普通文件的读写,会发生两次内存拷贝。
我们再看mmap的文件读写:
我们使用mmap的时候,系统会首先分配一段虚拟内存,并且在该过程中建立了vma和映射文件的关联,向上返回虚拟内存的首地址,此时还没有建立和物理内存的映射关系。等到第一次访问虚拟地址时,发生缺页异常,将对应的文件内容读取到页缓存,并建立虚拟内存和页缓存的映射关系,如此只有一次将文件数据读取页缓存的过程。
这里有个问题:mmap并不是直接将虚拟内存对应的物理内存和文件建立映射关系,二是内核将数据拷贝至页缓存,然后将虚拟内存直接映射到了页缓存上,从而避免的二次拷贝
这也就是mmap比普通文件读写要快的原因。mmap的其他相关知识可以访问后文的资料链接。
数据结构
每个unixFile关联了一个unixShm结构体,这个结构体用来描述一个已打开的共享文件。
1 | struct unixShm { |
而从注释上也可以看出来,每一个unixShm还有一个unixShmNode。而指向同一个unixShmNode的unixShm都通过pNext关联在一个链表内。使用同一个unixShmNode的shm链表被存储在unixShmNode的pfirst指针中。同一个数据库文件的不同的数据库连接(假设使用了WAL mode),包含的是不同的unixShm,但是是同一个unixShmNode。
unixShmNode从某种意义上来说是可以与unixInodeInfo关联在一起的,但是开发者可能不想让所有不使用shm(WAL mode)的数据连接也持有这些没有意义的数据,所以才做了分离,只做了unixInodeInfo内的pShmNode指针和unixShmNode的pInode指针的相互指向(绑定)。
1 | struct unixShmNode { |
下面我们分别看Sqlite的这4个接口函数
sqlite3OsShmMap
下面是shmMap原始的函数签名。
1 | static int unixShmMap( |
在sqlite中,每一个文件的映射是由一个一个region组合起来的(unixShmNode中的apRegion就是region指针的数组,nRegion就是这个数组的大小)。而unixShmMap这个函数就是获取第iRegion区域的mmap指针。其中每一个region的size是szRegion。(这个值从第一次调用之后就不可以在变了,会被存储在unixShmNode中的szRegion内)
第一个参数就是需要打开shm的数据库文件对象,第二个iRegion是需要获取的区域下标,szRegion是每一个区域的大小,bExtend表示是否可以扩展文件大小,最后是返回的mmap的地址指针。
sqlite设定了一个最小region size是32kb。根据上文说的,mmap是按照page size来映射文件的。一般情况下page size 在32位机器下是4k。所以,mmap一次映射最小(nShmPerMap)是1个region,但如果page size是64k,那么一次最小(nShmPerMap)就需要2个region了。
函数过程:
- 如果当前数据库连接的文件对象内共享文件没有打开,先打开文件。如果unixNodeInfo的pShmNode也是空的,那么将创建unixShmNode与unixNodeInfo绑定。在shm文件打开完成之后,会在shm的128个字节处加读锁。(在加读锁之前会判断是否是第一个打开该文件的进程,如果是,那么会把文件裁剪为3个字节,之所以是3个,一是出于调试的目的,二是3个字节小于shm文件的header大小,也不会影响业务逻辑)
- 根据最小region,计算出iRegion需要映射的region数量nReqRegion。判断是否已经映射(nReqRegion<unixShmNode.nRegion),如果还没有映射,那么执行第3步,否则执行第5步
- 判断映射文件的大小是否需要扩展,如果需要扩展,那么在扩展的文件空间对应的内存分页写入最后一个字节(下面会解释这个操作的原因)。
- 文件扩展完成之后,按照已有的mmap的region个数(unixShmNode.nRegion)和请求的nReqRegion个数来扩展apRegion。其中考虑到一次最少分配nShmPerMap个region,再把nShmPerMap个region拆分到apRegion中
- 返回unixShmNode.apRegion中第iRegion个region对应的mmap地址。
解释一下第三步,为什么扩展mmap文件的时候,需要在每一个分页最后写入一个字节?
Sqlite的解释是:从技术上来说,扩展一个文件,我们只需要在文件的最后的最后一个字节,写入一个字节就可以直接扩展文件的大小了。在扩展的空间的每一个内存分页大小的最后写入一个字节,可以减少之后mmap映射区间的时候SIGBUS的概率。
详细可以看 认真分析mmap:是什么 为什么 怎么用 - 胡潇 - 博客园 这篇文章最后的情景二,文件大小不是页的整倍数的情况。
再加上我们上文说到的页缓存的逻辑,大致可以猜测,因为只有在真正读写文件的时候,才会触发页缓存的cache查询。而在文件的每一个分页的最后写入一个字节,会使得页缓存未命中,而触发内核从磁盘读取以及在页缓存中映射这一页对应的文件。这样就会减少在mmap系统调用之后,用户每次访问文件映射的虚拟内存引起的缺页异常,即把耗时操作集中放在了创建的时候。
sqlite3OsShmUnmap
下面是函数的原型:
1 | static int unixShmUnmap( |
顾名思义,Unmap函数的操作结果是取消映射文件,如果deleteFlag是1的话还会清空共享缓存。
函数过程:
- 从unixInodeInfo中找到unixShmNode,遍历unixShmNode的first指针链表,找到当前sqlite_file的unixShm,并且从链表中断开。
- 减少unixShmNode的引用计数,如果计数到0了,那么就清空共享缓存(第三步)。
- 按照nShmPerMap的大小调用osMunmap方法,直到释放所有的mmap缓存。
sqlite3OsShmLock
下面是函数原型:
1 | static int unixShmLock( |
1 | #define SQLITE_SHM_UNLOCK 1 |
上面是shm锁的类型,也就是第4个参数flags的参数来源。UNLOCK、LOCK 和 SHARED、EXCLUSIVE分别组合,即4种组合。(其实只有unlock、shared、exclusive三种)
与数据库文件锁不太一样,shm的锁不能由excl<->shared,只能有unlock到任一锁或者任一锁到unlock。
与数据库文件锁一样的是,posix文件锁的特性。
上文中 unixShm 的数据结构中还有两个变量,分别是sharedMask和exclMask,分别表示当前这个数据库连接持有的对应shm的mmap文件锁类型。
请求的锁是SQLITE_SHM_UNLOCK
遍历使用当前unixShmNode的unixShm链表,如果已经没有其他unixShm加锁了,就调用unixShmSystemLock真正的移除文件锁,并且移除sharedMask和exclMask的标记位。否则就只是移除标记位。请求的锁是SQLITE_SHM_SHARED
遍历使用当前unixShmNode的unixShm链表,如果有exclusive锁,返回SQLITE_BUSY。如果当前进程内没有任何锁,就调用unixShmSystemLock在文件上加读锁,并把unixShm上的sharedMask置1。否则就只是置位。请求的锁是SQLITE_SHM_EXCLUSIVE
遍历使用当前unixShmNode的unixShm链表,如果有其他任何在锁状态,返回SQLITE_BUSY。否则就调用unixShmSystemLock在文件上加写锁,并且在exclMask上置1。
sqlite3OsShmBarrier
通过调用__sync_synchronize()函数实现的内存屏障。所有在屏障之前的代码必定会在屏障之后的代码执行之前完成执行。具体可以看后文的内存屏障相关文章。
参考
mmap相关
各种缓存(一) - ccxikka - 博客园
认真分析mmap:是什么 为什么 怎么用 - 胡潇 - 博客园
彻底理解mmap()
内存屏障
一文解决内存屏障 - 简书