webpack打包优化之webpack-bundle-analyzer

一开始以为 webpack-bundle-analyzer 是什么插件...后来才发现, 原来我用vue-cli安装的包, 在构建的时候, 多加个参数就可以了, 它还自动把浏览器打开了...

# build for production and view the bundle analyzer report
npm run build --report

无需配置, 只需一个参数, over...

让你的Vue项目支持scss/sass

回过头看了之前的Vue项目, 原来我用了2年的Vue了~

看了package.json, 有一个是"vue": "^1.0.21"...

现在Vue的最新稳定版本已经是2.5.2咯.

最近 vue init webpack my-project 了一个新项目, 发现webpack的配置变化挺大的. 在main.js引入了scss, 编译失败, 原来是loader没装.

运行下下面的两行命令, 装上loader, 就可以编译通过了.

npm install --save-dev node-sass
npm install --save-dev sass-loader

好吧, 又水了一篇文章哈哈哈.

LNMP环境运行Laravel出现500错误的解决

为了方便节省时间, 现在都是使用lnmp一键安装包搭建LNMP环境的. 今天恰好有个用Laravel开发的项目, 部署完一直500错误, 百思不得其解... 谷歌了一番, 记下来备用嘿嘿~

首先处理一下目录权限:

chmod -R 777 bootstrap/
chmod -R 777 storage/

再确认一下是否是open_basedir的问题, 方法是修改php.ini, 打开PHP的错误显示:

vim /usr/local/php/etc/php.ini
display_errors = On

改完php.ini要重启一下:

lnmp php-fpm restart

刷新页面, 如果有类似下面的错误:

Warning: require(): open_basedir restriction in effect. File(/home/wwwroot/***/bootstrap/autoload.php) is not within the allowed path(s): (/home/wwwroot/***/public/:/tmp/:/proc/) in /home/wwwroot/***/public/index.php on line 22

Warning: require(/home/wwwroot/***/bootstrap/autoload.php): failed to open stream: Operation not permitted in /home/wwwroot/***/public/index.php on line 22

Fatal error: require(): Failed opening required '/home/wwwroot/***/public/../bootstrap/autoload.php' (include_path='.:/usr/local/php/lib/php') in /home/wwwroot/***/public/index.php on line 22

打开/usr/local/nginx/conf/fastcgi.conf, 注释PHP_ADMIN_VALUE配置(最前面加个#号):

vim /usr/local/nginx/conf/fastcgi.conf
#fastcgi_param PHP_ADMIN_VALUE "open_basedir=$document_root/:/tmp/:/proc/";

最后重启一下就可以了, 当然php.ini得先改回去:

vim /usr/local/php/etc/php.ini
display_errors = Off
lnmp restart

还有.user.ini这个文件, 移动或者删除之前, 需要先执行下面的命令:

chattr -i .user.ini

我部署时是直接把它删了, 如果需要的话, 放在public目录下, 文件内容根据实际路径修改, 这里做个示例:

open_basedir=/home/wwwroot/www.abc.com/:/tmp/:/proc/

OK, 就写到这里~睡觉~

深入理解计算机系统(1)-计算机系统漫游

1. 计算机系统漫游

还是从hello world开始(hello.c):

#include <stdio.h>

int main()
{
    printf("hello, world\n");
}

逐步介绍它的整个生命周期: 被程序员创建 -> 到在系统上运行 -> 输出简单的消息 -> 然后终止.

1.1 信息就是位 + 上下文

大部分的现代操作系统都使用ASCII标准来表示文本字符, 即使用一个单字节大小的整数值来表示每个字符. 具体来说:

  1. 第一个字节的整数值是35, 对应的是字符"#";
  2. 第二个字节的整数值是105, 对应的是字符"i";
  3. 每个文本行以不可见的字符(整数值为10), 换行符, "n"结尾.

此外, 由ASCII字符构成的文件成为文本文件, 所有其他文件都称为二进制文件.

C编程语言的起源
  • C语言是贝尔实验室的Dennis Ritchie于1969-1973年创建;
  • C语言与Unix操作系统关系密切;
  • C语言小而简单;
  • C语言是为实践目的而设计的;
  • C语言是系统级编程的首选, 同时它也非常适用于应用级程序的编写;
  • C语言的指针是造成困惑和程序错误的一个常见原因;
  • C语言还缺乏对非常有用的抽象(类、对象和异常)的显式支持.

1.2 程序被其他程序翻译成不同的格式

为了在系统上运行C程序, 需要把每条C语句转化为低级机器语言指令, 然后这些指令按照一种称为可执行目标程序的格式打好包, 并以二进制磁盘文件的形式存放起来. 目标程序也称为可执行目标文件.

在Unix系统上, 从源文件到目标文件的转化是由编译器驱动程序完成的:

unix> gcc -o hello hello.c

GCC编译器读取源文件hello.c, 并翻译成一个可执行目标文件hello. 这个翻译的过程由4个阶段完成:

  hello.c         1          2         3         4           hello
源程序(文本) --> 预处理器 --> 编译器 --> 汇编器 --> 链接器 --> 可执行目标程序(二进制)
                (cpp)   ^  (ccl)  ^  (as)   ^  (ld)
                        |         |         |
                        |         |         |
                     hello.i   hello.s    hello.o
                     被修改的    汇编       printf.o
                     源程序      程序   可重定位目标程序
                     (文本)     (文本)     (二进制)

运行这4个阶段的程序(预处理器cpp, 编译器ccl, 汇编器as和链接器ld)一起构成了编译系统(compilation system).

GNU 项目
  • GCC是GNU(GNU’s Not Unix)项目开发出来的众多有用的工具之一;
  • GNU项目已开发出一个包含Unix操作系统所有主要部件的环境, 但内核除外; 该环境包括EMACS编辑器, GCC编译器, GDB调试器, 汇编器, 链接器, 处理二进制文件的工具等;
  • GCC编译器支持许多不同的语言, 能够针对不同的机器生成代码;
  • Linux受欢迎在很大程序上归功于GNU工具, 因为它们给Linux内核提供了环境.

1.3 了解编译系统如何工作是大有益处的

对于像hello.c的简单程序, 直接依赖编译系统生成正确有效的机器代码即可, 但有以下原因促使我们程序员必须了解编译系统是如何工作的:

优化程序性能

为了在C程序中做出好的编码选择, 需要了解一些机器代码以及编译器将不同的C语句转化为机器代码的方式.

  • switch VS if-then-else
  • 函数调用开销
  • while VS for
  • 指针引用 VS 数组索引
  • 循环求和结果放在本地变量 VS 结果放在引用传递过来的参数
理解链接时出现的错误
  • 链接器报告无法解析一个引用
  • 静态变量 VS 全局变量
  • 不同的C文件定义同名全局变量
  • 静态库 VS 动态库
  • 在命令行上排列库的顺序会有什么影响
避免安全漏洞

学习安全编程的第一步就是理解数据和控制信息存储在程序栈上的方式会引起的后果.

  • 理解堆栈原理
  • 避免缓冲区溢出

1.4 处理器读并解释存储在存储器中的指令

hello.c源程序已经被翻译为可执行目标文件hello并存放在磁盘上. 要想在Unix上运行它, 把文件名输入到Shell(外壳)的应用程序中:

unix> ./hello
hello, world
unix>
  • Shell是一个命令行解析器, 它输出一个提示符, 等待你输入一个命令行, 然后执行这个命令;
  • 如果该命令不是内置的外壳命令, 外壳会假设这是一个可执行文件的名字, 加载并运行之.
1.4.1 系统的硬件组成

1.总线(贯穿整个系统的电子管道)

  • 携带信息字节并负责在各个部件间传递;
  • 通常设计成传送定长的字节块, 也就是字(word);
  • 字中的字节数(即字长)是一个基本的系统参数, 在各个系统中不尽相同;
  • 大多数机器有的是4个字长(32位), 有的是8(64位);
  • 为了讨论方便, 假定字长为4个字节, 并且总线每次只传送1个字.

2.I/O设备(系统与外部世界的联系通道)

  • 每个I/O设备都通过一个控制器或适配器与I/O总线相连;
  • 控制器和适配器之间的区别主要在于它们的封装方式;
  • 控制器是置于I/O设备本身的或者系统的主板上的芯片组;
  • 适配器是一块插在主板插槽上的卡.

3.主存(临时存储设备)

  • 由一组动态随机存取存储器(DRAM)芯片组成;
  • 逻辑上, 存储器是一个线性的字节数组, 每个字节都有其唯一的地址(数组索引), 地址从零开始;
  • 组成程序的每条机器指令都由不同数量的字节构成.

4.处理器(解释或执行存储在主存中指令的引擎)

  • 处理器的核心是一个字长的存储设备(或寄存器), 称为程序计数器(PC);
  • 在任何时刻, PC都指向主存中的某条机器语言指令(即含有该条指令中的地址);
  • 从系统通电到断电, 处理器一直在执行程序计数器指向的指令, 再更新程序计数器, 使其指向下一条指令.
1.4.2 运行hello程序

当我们从键盘输入./hello后, 外壳程序将字符逐一读入寄存器, 再把它存放到存储器中:


从键盘上读取hello命令

CPU
|-----------------------|
| PC 寄存器文件 <-> ALU |
|        ^              |            存储器总线
|        |              |                 |
|        v          系统总线              v
|    总线接口 <-----------------> I/O桥 ----> 主存储器 "hello"
|-----------------------|          ^
                                   |
==================== IO总线 =====================|=|=|=======
    ^          |               |             扩展槽, 留待网络
    |          |               |             适配器一类的设备
USB控制器  图形适配器      磁盘控制器             使用
  ^   |        |               |
  |   |        |               |
键盘 鼠标    显示器           磁盘
  ^
  |
用户输入
"hello"

敲击回车键后, 外壳程序知道命令输入已结束, 接着执行一系列的指令来加载可执行的hello文件, 将hello目标文件中的代码和数据(hello world\n)从磁盘复制到主存.

利用直接存储器存取(DMA)技术, 数据可以不通过处理器而直接从磁盘到达主存:


从磁盘加载可执行文件到主存

CPU
|-----------------------|
| PC 寄存器文件 --- ALU |
|        |              |            存储器总线
|        |              |                 |
|        |          系统总线              v
|    总线接口 ------------------- I/O桥 ----> 主存储器 "hello 代码"
|-----------------------|          ^                   "hello world\n"
                                   |
==================== IO总线 =====================|=|=|=======
    |          |               ^             扩展槽, 留待网络
    |          |               |             适配器一类的设备
USB控制器  图形适配器      磁盘控制器             使用
  |   |        |               ^
  |   |        |               |
键盘 鼠标    显示器           磁盘
                     存储在磁盘上的hello可执行
                              文件

当目标文件中的代码和数据被加载到主存, 处理器就开始执行hello程序的main程序中的机器语言指令. 这些指令将hello, world\n字符串中的字节从主存复制到寄存器文件, 再从寄存器文件中复制到显示设备, 最终显示在屏幕上.


将输出字符串从内存写到显示器

CPU
|-----------------------|
| PC 寄存器文件 <-> ALU |
|        ^              |            存储器总线
|        |              |                 |
|        v          系统总线              v
|    总线接口 <-----------------> I/O桥 ----> 主存储器 "hello"
|-----------------------|          |
                                   |
==================== IO总线 =====================|=|=|=======
    ^          |               |             扩展槽, 留待网络
    |          v               |             适配器一类的设备
USB控制器  图形适配器      磁盘控制器             使用
  ^   ^        |               |
  |   |        v               |
键盘 鼠标    显示器           磁盘

1.5 高速缓存至关重要

系统花费了大量的时间把信息从一个地方挪到另一个地方:

  • hello程序的机器指令: 磁盘->主存->处理器;
  • 数据"hello worldn": 磁盘->主存->显示设备.

这些复制操作就是开销, 减缓了程序"真正"的工作. 因此系统的设计者的一个主要目标就是使得这些复制操作尽可能快地完成.


高速缓存存储器

CPU
|-----------------------------|
| 高速缓存 <-> 寄存器 <-> ALU |
|   存储器     文件           |          存储器总线
|        ^      ^             |               |
|        |      |             |               |
|        v      v          系统总线           v
|        总线接口 <-----------------> I/O桥 ----> 主存储器
|-----------------------------|
  • 较大的存储设备要比较小的存储设备运行得慢, 而快速设备的造价远高于同类的低速设备;
  • 系统设计者采用了更小更快的存储设备, 即高速缓存存储器(简称高速缓存);
  • 它作为暂时的集结区域, 存放处理器近期可能会需要的信息;
  • L1和L2高速缓存是用一种叫做静态随机访问存储器(SRAM)的硬件技术实现.

1.6 存储设备形成层次结构

从上至下, 访问速度越来越慢, 容量越来越大, 每字节造价越来越便宜.

  1. L0: 寄存器 - CPU寄存器保存来自高速缓存存储器的字
  2. L1: L1高速缓存(SRAM) - L1高速缓存保存取自L2高速缓存的高速缓存行
  3. L2: L2高速缓存(SRAM) - L2高速缓存保存取自L3高速缓存的高速缓存行
  4. L3: L3高速缓存(SRAM) - L3高速缓存保存取自主存的高速缓存行
  5. L4: 主存(DRAM) - 主存保存取自本地硬盘的磁盘块
  6. L5: 本地二级存储(本地磁盘) - 本地磁盘保存取自远程网络服务器上磁盘的文件
  7. L6: 远程二级存储(分布式文件系统, Web服务器)

1.7 操作系统管理硬件

我们可以把操作系统看成是应用程序和硬件之间插入的一层软件, 所有应用程序对硬件的操作尝试都必须通过操作系统:


计算机系统的分层视图

|-------------------------|
|        应用程序         | \
|-------------------------|  -> 软件
|        操作系统         | /
|-------------------------|
| 处理器 | 主存 | I/O设备 | --> 硬件
|-------------------------|

操作系统有两个基本功能:

  1. 防止硬件被失控的应用程序滥用;
  2. 向应用程序提供简单一致的机制来控制复杂而又通常大相径庭的低级硬件设备.

操作系统通过几个基本的抽象概念(进程, 虚拟存储器和文件)来实现这两个功能.


操作系统提供的抽象表示

|-------------------------|
|          进程           |
|        -----------------|
|        |   虚拟存储器   |
|        |      ----------|
|        |      |   文件  |
|        |      |         |
| 处理器 | 主存 | I/O设备 |
|-------------------------|
Unix和Posix
  • 20世纪60年代是大型, 复杂操作系统盛行的年代, 如IBM的OS/360和Honeywell的Multics系统;
  • 贝尔实验室曾经是Multics项目的最初参与者, 但是因为项目复杂和缺乏进展于1969年退出;
  • 之后一组贝尔实验室的研究人员(Ken Thompson, Dennis Ritchie, Doug Mcllroy & Joe Ossanna)从1969年开始在DEC PDP-7计算机上完全用机器语言编写了一个简单得多的系统;
  • 1970年Brian Kernighan命名为"Unix";
  • 1973年用C语言重新编写内核;
  • 1974年开始对外发布.

发布之后不同的Unix厂商加入新的, 往往不兼容的特性来使它们的程序与众不同, 也带来很多麻烦, 为了阻止这种趋势, IEEE(电气和电子工程师协会)开始努力标准化Unix的开发, 后来由 Richard Stallman命名为"Posix", 称为Posix标准.

1.7.1 进程
  • 进程是操作系统对一个正在运行的程序的一种抽象;
  • 在一个系统上可以同时运行多个进程, 而每个进程都好像在独占地使用硬件;
  • 单/多核系统中, 一个CPU看上去都像是在并发地执行多个进程, 这是通过处理器在进程间切换来实现的, 这种交错执行的机制称为上下文切换.
  • 操作系统保持跟踪进程运行所需的所有状态信息, 这种状态, 也就是上下文. 它包含许多信息, 例如PC和寄存器文件的当前值, 以及主存的内容;
  • 在任一时刻, 单处理器都只能执行一个进程的代码;
  • 当操作系统决定要把控制权从当前进程转移到某个新进程时, 就会进行上下文切换(保存当前上下文, 恢复新进程上下文, 然后控制权传递到新进程, 新进程从上次停止的地方开始);

进程的上下文切换(从上到下表示时间的流逝)

            | 进程A  |  进程B  |
            |   |    |         | - 用户代码
read     -> |   v    |         |
            |    -- \|         | - 内核代码(上下文切换)
            |        |\ --     |
            |        |    |    | - 用户代码
磁盘中断 -> |             v    |
            |        |/ --     | - 内核代码(上下文切换)
            |       /|         |
read返回 -> |   |--  |         | - 用户代码
            |   v    |         |
1.7.2 线程
  • 一个进程实际上可以由多个称为线程的执行单元组成;
  • 每个线程都运行在进程的上下文中, 并共享同样的代码和全局数据;
  • 一般来说线程比进程更高效;
  • 当有多处理器的时候, 多线程也是一种使程序更快的方法.
1.7.3 虚拟存储器
  • 虚拟存储器是一个抽象概念, 它为每个进程提供了一个假象: 每个进程都在独占地使用主存;
  • 每个进程看到的是一致的存储器, 称为虚拟地址空间;
  • 在Linux中, 地址空间最上面的区域是为操作系统中的代码和数据保留的;
  • 地址空间的底部区域存放用户进程定义的代码和数据.

进程的虚拟地址空间(地址从下往上增大)

|---------------------------------------------|
|---------------------------------------------|
|      内核虚拟存储器(用户代码不可见的存储器) |
|---------------------------------------------|
|              用户栈(运行时创建的)           |
|---------------------------------------------|
|                      ^                      |
|                      |                      |
|                      v                      |
|---------------------------------------------|
|      共享库的存储器映射区域(printf函数)     |
|---------------------------------------------|
|                      ^                      |
|                      |                      |
|---------------------------------------------|
|       运行时堆(在运行时由malloc创建的)      |
|---------------------------------------------|
|                  读/写数据                  | \
|---------------------------------------------|  -> 从hello可执行文件加载进来的
|             只读的代码和数据                | /
|---------------------------------------------| 0x08048000(32) / 0x00400000(64)
|---------------------------------------------| 0x0

从最低的地址开始, 逐步向上介绍:

1.程序代码和数据:

  • 对于所有的进程来说, 代码是从同一固定地址开始, 紧接着的是和C全局变量相对应的数据位置;
  • 代码和数据区是直接按照可执行目标文件的内容初始化的.

2.堆:

  • 代码和数据区后紧随着的是运行时堆;
  • 代码和数据区是在进程一开始运行就被规定了大小, 与此不同, 当调用如malloc和free这样的C标准库函数时, 堆可以在运行时动态地扩展和收缩.

3.共享库:

  • 大约在地址空间的中间部分是一块用来存放像C标准库和数学库这样共享库的代码和数据的区域;
  • 共享库的概念非常强大, 也相当难懂.

4.栈:

  • 位于用户虚拟地址空间顶部的是用户栈, 编译器用它来实现函数调用;
  • 和堆一样, 用户栈在程序执行期间可以动态地扩展和收缩;
  • 调用函数时栈增长, 函数返回时栈收缩.

5.内核虚拟存储器:

  • 内核总是驻留在内存中, 是操作系统的一部分;
  • 地址空间顶部的区域是为内核保留的, 不允许应用程序读写这个区域的内容或者直接调用内核代码定义的函数.

虚拟存储器的运作需要硬件和操作系统软件之间精密复杂的交互, 包括对处理器生成的每个地址的翻译. 其基本思想是把一个进程虚拟存储器的内容存储在磁盘上, 然后用主存作为磁盘的高速缓存.

1.7.4 文件
  • 文件就是字节序列, 仅此而已;
  • 文件这个简单而精致的概念拥有极其丰富的内涵, 它向应用程序提供了一个统一的视角, 来看待系统中可能含有的所有I/O设备.

1.8 系统之间利用网络通信

  • 现代系统经常通过网络和其他系统连接到一起. 从一个单独的系统看, 网络可视为一个I/O设备;
  • 当系统从主存将一串字节复制到网络适配器时, 数据流经过网络到达另一台机器, 而不是其他地方;
  • 相似地, 系统可以读取从其他机器发送来的数据, 并把数据复制到自己的主存.

1.9 重要主题

系统是硬件和系统软件互相交织的集合体, 它们必须共同协作以达到运行应用程序的最终目的. 我们在此强调几个贯穿计算机系统所有方面的重要概念.

1.9.1 并发和并行
  • 并发(concurrency)是一个通用的概念, 指一个同时具有多个活动的系统;
  • 并行(parallelism)指的是用并发使一个系统运行得更快. 并行可以在计算机系统的多个抽象层次上运用.

1.线程级并发

  • 传统意义上, 这种并发执行只是模拟出来的, 是通过正在执行的进程间快速切换的方式实现的, 这种配置称为单处理器系统;
  • 当构建一个由单操作系统内核控制的多处理器组成的系统时, 就得到了一个多处理器系统;
  • 超线程, 有时称为同时多线程(simultaneous multi-threading), 是一项允许一个CPU执行多个控制流的技术.

Intel Core i7的组织架构, 4个处理器核集成到一个芯片上

                  处理器封装包
|---------------------------------------------|
|---------------------------------------------|
|        核0        | ... |        核3        |
|-------------------|     |-------------------|
|  寄存器           |     |  寄存器           |
|    |              |     |    |              |
|  L1数据   L1指令  |     |  L1数据   L1指令  |
| 高速缓存 高速缓存 |     | 高速缓存 高速缓存 |
|    |        |     |     |    |        |     |
| L2统一的高速缓存  |     |  L2统一的高速缓存 |
|-------------------|     |-------------------|
|                 |         |                 |
|         L3统一的高速缓存(所有的核共享)      |
|                     |                       |
|---------------------|-----------------------|
|---------------------|-----------------------|
                      |
                     主存

2.指令级并行

  • 在较低的抽象层次上, 现代处理器可以同时执行多条指令的属性称为指令级并行;
  • 使用流水线(pipelining)实现指令级并行;
  • 如果处理器可以达到比一个周期一条指令更快的执行速率, 就称之为超标量(superscalar)处理器, 大多数现代处理器都支持超标量操作.

3.单指令, 多数据并行

在最低层次上, 许多处理器拥有特殊的硬件, 允许一条指令产生多个可以并行执行的操作, 即SIMD并行. 例如较新的Intel和AMD处理器都具有并行地对4对单精度浮点数(C数据类型float)做加法的指令.

1.9.2 计算机系统中抽象的重要性

抽象的使用是计算机科学中最为重要的概念之一.


操作系统提供的一些抽象

|--------------------------------------|
|                虚拟机                |
|          ----------------------------|
|          |                           |
|          |           进程            |
|          |---------------------------|
|          |指令级结构|   虚拟存储器   |
|          |          |      ----------|
|          |          |      |   文件  |
|          |          |      |         |
| 操作系统 |  处理器  | 主存 | I/O设备 |
|--------------------------------------|

注: 计算机系统中的一个重大主题就是提供不同层次的抽象表示, 来隐藏实际实现的复杂性.
  • 在处理器里, 指令集结构提供了对实际处理器硬件的抽象;
  • 文件是对I/O的抽象;
  • 虚拟存储器是对程序存储器的抽象;
  • 进程是对一个正在运行的程序的抽象;
  • 虚拟机则是对整个计算机(包括操作系统、处理器和程序)的抽象.

Redis的设计与实现(1)-SDS简单动态字符串

现在在高铁上, 赶着春节回家过年, 无座站票, 电脑只能放行李架上, 面对着行李架撸键盘--看过<Redis的设计与实现>这本书, 突然想起, 便整理下SDS的内容, 相对后面的章节, 算是比较简单的~

大多数情况下, Redis使用SDS(Simple Dynamic String, 简单动态字符串)作为字符串表示, 比起C字符串, SDS具有以下优点:

  1. 常数复杂度获取字符串长度;
  2. 杜绝缓冲区溢出;
  3. 减少修改字符串时带来的内存重分配次数;
  4. 二进制安全;
  5. 兼容部分C字符串函数.

以上列举的优点, 也算是这篇文章的主要内容. 先从定义说起吧:

1. SDS的定义

SDS结构定义如下(sds.h/sdshdr):

struct sdshdr {
    // 记录buf数组中已使用字节的数量, 等于SDS所保存字符串的长度
    int len;
    // 记录buf数组中未使用字节的数量
    int free;
    // 字节数组, 用于保存字符串
    char buf[];
}
  1. len属性记录了已使用的字节数量(字符串长度);
  2. free属性的值为0, 表示这个SDS没有未使用的空间;
  3. free属性的值为5, 表示这个SDS保存了一个5字节长的字符串;
  4. buf属性是一个char类型的数组, 数组的最后一个字节保存了空字符0.

SDS遵循C字符串以空字符串结尾的惯例, 空字符不计入SDS的len属性, 即额外为空字符分配了1字节的空间, 并且添加空字符到字符串末尾均由SDS函数自动完成, 对使用者完全透明. 该特性带来的好处是, SDS可以直接复用C字符串函数库的部分函数.

2. SDS与C字符串的区别

2.1 常数复杂度获取字符串长度

  1. 由于C字符串不记录自身长度, 所以获取长度时需要遍历整个字符串, 直到遇到空字符0为止, 该操作的复杂度为O(N);
  2. 由于SDS在len属性中记录了SDS本身的长度, 所以获取一个SDS的长度的复杂度为O(1).

Redis使用SDS, 将获取字符串长度所需的复杂度从O(N)降低到O(1), 确保获取字符串长度的工作不会成为Redis的性能瓶颈.

2.2 杜绝缓冲区溢出

由于C字符串不记录自身长度, 以函数strcat来说, 当执行该函数时, 都是认为已经为dest分配了足够的内存容纳src字符串, 但如果该假设不成立, 就会产生缓冲区溢出.

然而, SDS的字符串空间分配策略, 从根本上杜绝了缓冲区溢出的可能性: 当SDS-API需要对SDS进行修改时, API会先检查SDS的空间是否满足修改所需的需求, 如果不满足的话, API会自动扩容至所需大小, 再执行修改操作. 所以, SDS无需手工维护SDS的空间大小, 也不会产生缓冲区溢出的问题.

2.3 减少修改字符串时带来的内存重分配次数

由于C字符串不记录自身长度, 所以每次增长或缩减字符串, 需要对保存这个C字符串的数组进行一次内存重分配操作:

1.如果程序执行的是增长字符串的操作, 比如拼接操作append, 需要进行内存重分配操作, 扩展底层数组至合适大小, 否则将会产生缓冲区溢出;
2.如果程序执行的是缩短字符串的操作, 比如截断操作trim, 需要进行内存释放操作, 否则将会产生内存泄露.

Redis作为数据库, 经常用于速度要求严苛, 数据被频繁修改的场合, 减少内存的重分配次数能提高性能. SDS通过未使用空间解除了字符串长度底层数组长度之间的关联: 在SDS中, buf数组的长度不一定就是字符数加一, 数组里面可以包含未使用的字节, 而这些字节的数量就由SDS的free属性记录.

通过未使用空间, SDS实现了空间预分配和惰性空间释放两种优化策略.

1.空间预分配

当SDS-API对SDS进行修改, 并且需要对SDS进行空间扩展的时候, 程序不仅会为SDS分配修改所需的空间, 还会为SDS分配额外的未使用空间.

额外分配的未使用空间数量, 由以下公式决定:

  1. 如果对SDS进行修改之后, SDS的长度(即len属性)将小于1MB, 那么程序分配和len属性同样大小的未使用空间, 这时SDS的len属性的值将和free属性的值相同: 比如修改之后, SDS的len将变成13字节, 那么程序也会分配13字节的未使用空间, buf长度将变成 13Byte + 13Byte + 1Byte = 27Byte;
  2. 如果对SDS进行修改之后, SDS的长度将大于等于1MB, 那么程序会分配1MB的未使用空间: 比如修改之后, SDS的len将变成30MB, 那么程序会分配1MB的未使用空间, buf长度将变成 30MB + 1MB + 1Byte.

通过空间预分配策略, Redis可以减少连续执行字符串增长操作所需的内存重分配次数.

2.惰性空间释放

惰性空间释放用于优化SDS的字符串缩短操作: 当SDS-API需要缩短SDS字符串时, 程序并不会立即回收内存, 而是使用free属性将这些字节的数量记录起来, 并等待将来使用.

当然, SDS也提供了相应的API真正地释放SDS的未使用空间, 无需担心该策略带来的内存浪费问题.

2.4 二进制安全

C字符串除了末尾, 不能包含空字符, 否则最先被程序读入的空字符将被误认为是字符串结尾, 所以C字符串只能保存文本数据, 而不能保存像图片, 音频, 视频, 压缩文件这样的二进制数据.

Redis为了可以适用于各种不同的场景, SDS-API都是二进制安全的(binary-safe), 所有SDS-API都会以二进制的方式处理buf数组里面的数据, 不限制, 不过滤, 不假设, 写入什么就读到什么.

2.5 兼容部分C字符串函数

虽然SDS-API是二进制安全的, 但它们一样遵循C字符串以空字符结尾的惯例: 这些API总是将SDS保存的数据末尾数组为空字符, 并且总会在为buf分配空间时多分配一个字节来容纳这个空字符, 以复用<string.h>库定义的函数.

3. SDS的API

函数 作用 复杂度
sdsnew 创建一个包含给定C字符串的SDS O(N), N为给定C字符串的长度
sdsempty 创建一个不包含任何内容的空SDS O(1)
sdsfree 释放给定的SDS O(N), N为被释放SDS的长度
sdslen 返回SDS的已使用空间字节数 这个值可以通过读取SDS的len属性来直接获得, 复杂度为O(1)
sdsavail 返回SDS的未使用空间字节数 这个值可以通过读取SDS的free属性来直接获得, 复杂度为O(1)
sdsdup 创建一个给定SDS的副本(copy) O(N), N为给定C字符串的长度
sdsclear 清空SDS保存的字符串内容 因为惰性空间释放策略, 复杂度为O(1)
sdscat 将给定C字符串拼接到另一个SDS字符串的末尾 O(N), N为被拼接C字符串的长度
sdscatsds 将给定SDS字符串拼接到另一个SDS字符串的末尾 O(N), N为被拼接SDS字符串的长度
sdscpy 将给定的C字符串复制到SDS里面, 覆盖SDS原有的字符串 O(N), N为被复制SDS字符串的长度
sdsgrowzero 用空字符将SDS扩展至给定长度 O(N), N为扩展新增的字节数
sdsrange 保留SDS给定区间内的数据, 不在区间内的数据会被覆盖或清除 O(N), N为被保留数据的字节数
sdstrim 接受一个SDS和一个C字符串作为参数, 从SDS中移除所有在C字符串中出现过的字符 O(N^2), N为给定C字符串的长度
sdscmp 对比两个SDS字符串是否相同 O(N), N为两个SDS钟较短的那个SDS的长度

4. 总结

  • Redis在大多数情况下使用SDS作为字符串的表示;
  • 相比C字符串, SDS具有以下优点:
  1. 常熟复杂度获取字符串长度;
  2. 杜绝缓冲区溢出;
  3. 减少修改字符串长度时所需的内存重分配次数;
  4. 二进制安全;
  5. 兼容部分C字符串函数.
  • C字符串和SDS之间的区别:
C字符串SDS
获取字符串长度的复杂度为O(N)获取字符串长度的复杂度为O(1)
API是不安全的,可能造成缓冲区溢出API是安全的,不会造成缓冲区溢出
修改字符串长度N次必然需要执行N次内存重分配修改字符串长度N次最多需啊哟执行N次内存重分配
可以使用所有<string.h>库中的函数可以使用一部分<string.h>库中的函数

以上笔记都是整理自<Redis的设计与实现>, 真的很感谢作者, 也希望自己能坚持写下去^_^.