我的vim开发环境搭建(2): 常用的vim插件

1. NERDTree

前文中,我主要讲了一堆软件的升级,其主要原因是为了支持YouCompleteMe这个安装起来超级复杂的插件,其余的插件安装很简单,有了vim-plug后,只需要2步,以NERDTree(目录树)为例

第1步,在.vimrc中添加插件项目地址,然后在vim中执行:PlugInstall命令(也可以直接克隆到plug#begin指定的目录,这点前文已经讲过):

1
2
3
4
5
6
7
8
call plug#begin('~/.vim/plugged')

" NERDTree插件的github网址(不包含前缀https://github.com/)
Plug 'scrooloose/nerdtree'
" 其他插件
" ...

call plug#end()

第2步,添加插件相关的配置脚本,可以参考插件项目的README:

1
2
3
4
5
nmap <Leader><Leader> :NERDTreeToggle<CR>
let NERDTreeWinSize=32 " 设置NERDTree子窗口宽度
let NERDTreeWinPos="right" " 设置NERDTree子窗口位置
let NERDTreeShowHidden=1 " 显示隐藏文件
let NERDTreeMinimalUI=1 " NERDTree 子窗口中不显示冗余帮助信息

前文配置过<Leader>即分号(;)的代码,nmap这句就是配置快捷键,也就是连按2个;就等价于执行:NERDTreeToggle命令,打开树形目录,如下图所示:

树形目录

后面的则是插件的配置,在注释中也给出了。使用方式可以参考项目的README,vim可以通过Ctrl+W+上/下/左/右在不同切分窗口中切换,当光标在树形目录中时可以用上下键移动:

按键 功能
Enter 如果光标所在行是目录,展开当前目录,否则直接打开该文件
s 纵向切分窗口中打开光标所在行对应文件,等价于:split <filename>
i 横向切分窗口中打开光标所在行对应文件,等价于:vsplit <filename>
r 更新树形目录
u 将根目录退回到上一层
C 将根目录变回光标所在行对应目录

2. YouCompleteMe

1
Plug 'Valloric/YouCompleteMe'

YouCompleteMe(简称YCM)可谓是vim环境下C/C++补全的标配了,不同于其他插件,YCM非常重量级,200多M,而且它对vim版本要求比较高,还要求vim支持python,最后还需要用clang来编译,因此前文做的大量工作都是为这个插件准备的。对于较新的Linux系统,手动编译完高版本的vim后,直接调用./install.sh --clang-completer就行,它会自动下载新版本的clang,但是对CentOS 6.10行不通,因此之前我手动编译了高版本的clang。

虽然我也找过替代品,但亲自尝试的结果是,现阶段补全C/C++确实没有比YCM体验更好的。

2.1 YCM的安装

之前说过YCM比较大,考虑到国内访问github速度之慢,使用vim-plug安装不如手动克隆,而且这个项目有不少子模块,因此需要git submodule,命令如下所示(注意要先进入~/.vim/plugged目录):

1
2
git clone https://github.com/Valloric/YouCompleteMe.git
git submodule update --init --recursive

可以借助代理来加速,不过我是直接下载完毕后将整个目录压缩打包保存起来,下次安装直接上传解压即可。

YCM的安装是利用python脚本完成的,不过安装时可能会提示python缺少某些模块。那是因为之前编译python时没有安装依赖项,参考前文的2.1节。安装依赖项后重新编译python即可。

进入YouCompleteMe目录,使用本地的gcc、g++、cmake、clang来安装:

1
2
CC="$LOCAL/gcc-5.4.0/bin/gcc" CXX="$LOCAL/gcc-5.4.0/bin/g++" ./install.py \
--clang-completer --system-libclang

最终会在third_party/ycmd目录下生成ycm_core.so

2.2 YCM的配置

.vimrc文件中添加如下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
"" YCM配置
" 全局YCM配置文件路径
let g:ycm_global_ycm_extra_conf = '~/.ycm_extra_conf.py'
let g:ycm_confirm_extra_conf = 0 " 不提示是否载入本地ycm_extra_conf文件
let g:ycm_min_num_of_chars_for_completion = 2 " 输入第2个字符就罗列匹配项

" Ctrl+J跳转至定义、声明或文件
nnoremap <c-j> :YcmCompleter GoToDefinitionElseDeclaration<CR>|

" 语法关键字、注释、字符串补全
let g:ycm_seed_identifiers_with_syntax = 1
let g:ycm_complete_in_comments = 1
let g:ycm_complete_in_strings = 1
" 从注释、字符串、tag文件中收集用于补全信息
let g:ycm_collect_identifiers_from_comments_and_strings = 1
let g:ycm_collect_identifiers_from_tags_files = 1

" 禁止快捷键触发补全
let g:ycm_key_invoke_completion = '<c-z>' " 主动补全(默认<c-space>)
noremap <c-z> <NOP>

" 输入2个字符就触发补全
let g:ycm_semantic_triggers = {
\ 'c,cpp,python,java,go,erlang,perl': ['re!\w{2}'],
\ 'cs,lua,javascript': ['re!\w{2}'],
\ }

let g:ycm_show_diagnostics_ui = 0 " 禁用YCM自带语法检查(使用ale)

" 防止YCM和Ultisnips的TAB键冲突,禁止YCM的TAB
let g:ycm_key_list_select_completion = ['<C-n>', '<Down>']
let g:ycm_key_list_previous_completion = ['<C-p>', '<Up>']

输入2个字符即可触发补全操作,对C++而言,输入.::->也会触发补全操作。

除了补全,YCM本身也含有跳转功能,这里设置快捷键为Ctrl+J。但是目前似乎还不太完善,比如有时候只能跳转到声明无法跳转到定义。这些功能可能还需要借助ctags、cscope来辅助。

YCM对C/C++的补全、跳转是基于.ycm_extra_conf.py文件的,比如指定语言种类、标准、头文件目录、编译选项,其中编译选项是用来实现语法检查的,这里禁止了YCM自身的语法检查,使用后文将介绍的ALE插件来完成。其配置参考我的博客:为YCM配置ycm_extra_conf脚本

CentOS配置比较简单,C头文件都在/usr/include下,C++头文件则在自己安装的gcc目录下:

1
2
3
4
5
6
'-isystem',
'/usr/include',
'-isystem',
os.environ['HOME'] + '/local/gcc-5.4.0/include/c++/5.4.0',
'-isystem',
os.environ['HOME'] + '/local/gcc-5.4.0/include/c++/5.4.0/x86_64-unknown-linux-gnu'

需要注意的是x86_64-unknown-linux-gnu目录必须添加进去,否则会导致C++标准库无法补全,因为C++标准库头文件包含了该目录下的各种头文件,比如thread包含的3个头文件:

1
2
3
#include <bits/functexcept.h>
#include <bits/functional_hash.h>
#include <bits/gthr.h>

其中前2个都是出自$GCC_DIR/include/c++/5.4.0/bits目录,而第3个则是出自$GCC_DIR/include/c++/5.4.0/x86_64-unknown-linux-gnu/bits目录,因此必须把这个目录添加进去,否则头文件会解析失败。

3 ale静态语法检查

3.1 ale的安装和配置

1
Plug 'w0rp/ale'

其配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
"" ale
let g:ale_linters = {
\ 'c': ['gcc', 'cppcheck'],
\ 'cpp': ['g++', 'cppcheck'],
\ }
let g:ale_c_gcc_options = '-Wall -O2 -std=c99
\ -I .
\ -I /usr/include'
let g:ale_cpp_gcc_options = '-Wall -O2 -std=c++11
\ -I .
\ -I /usr/include
\ -I $HOME/local/gcc-5.4.0/include/c++/5.4.0'
let g:ale_c_cppcheck_options = ''
let g:ale_cpp_cppcheck_options = ''

let g:ale_linters_explicit = 1 " 只显示运行ale_linters的文件
let g:ale_completion_delay = 500
let g:ale_echo_delay = 20
let g:ale_lint_delay = 500
let g:ale_echo_msg_format = '[%linter%] %code: %%s'
let g:ale_lint_on_text_changed = 'normal' " 防止YCM不停补全
let g:ale_lint_on_insert_leave = 1

主要有用的是前面5个,后面照搬即可。ale_c_gcc_optionsale_cpp_gcc_options指定使用gcc/g++进行静态语法检查时的选项,和编译时的选项相同,类似YCM的.ycm_extra_conf.py,但是必须在.vimrc中配置而不能作为项目独立的配置文件。

另外,除了gcc/g++外,ale还使用了cppcheck进行语法检查,它能够检查出一些未定义的行为,比如没有fclose关闭打开的FILE指针。从源安装的cppcheck版本较老,不支持C++11的语法,因此去下载cppcheck源码手动编译。

3.2 安装cppcheck

要支持HAVE_RULES,需要安装pcre,下载源码解压后,手动./configure --prefix=<dirname>makemake install安装,然后在~/.bashrc中添加如下代码以指定gcc的包含路径和库路径:

1
2
3
4
export C_INCLUDE_PATH=$LOCAL/pcre/include:$C_INCLUDE_PATH
export CPLUS_INCLUDE_PATH=$LOCAL/pcre/include:$CPLUS_INCLUDE_PATH
export LD_LIBRARY_PATH=$LOCAL/pcre/lib:$LD_LIBRARY_PATH
export PATH=$LOCAL/pcre/bin:$PATH

然后进入cppcheck源码目录按照如下方式编译:

1
2
make SRCDIR=build CFGDIR=$LOCAL/bin/cfg HAVE_RULES=yes
make install PREFIX=$LOCAL CFGDIR=$LOCAL/bin/cfg

然后将$LOCAL/bin添加到.bashrc的环境变量中:

1
export PATH=$LOCAL/bin:$PATH

之后ale便可使用cppcheck进行语法检查,比如C++11语法下的资源泄漏检查:

ale检查资源泄漏

除此之外还有其他功能,可参考cppcheck官网的Undefined behaviour一栏。

4. 其他插件

其实最重要的补全、跳转插件都有了,其余的插件我也没详细研究,但也都配置了,使用开箱即用的功能,没有特别定制。

4.1 Ultisnips

1
2
Plug 'SirVer/ultisnips'
Plug 'honza/vim-snippets'

很多IDE都有自动生成代码的功能,比如输入main然后按回车或TAB键会自动生成main函数的模板代码,输入for按回车生成可供选择的多种for循环的模板代码。这里第1个插件提供了代码生成的功能,第2个插件则提供了各自语言的模板代码。

1
2
3
4
5
6
7
" Do not use <tab> if you use YouCompleteMe
let g:UltiSnipsExpandTrigger="<tab>"
let g:UltiSnipsJumpForwardTrigger="<c-b>"
let g:UltiSnipsJumpBackwardTrigger="<c-z>"

" If you want :UltiSnipsEdit to split your window.
let g:UltiSnipsEditSplit="vertical"

暂时我还只停留在简单地使用现成模板上,比如cl生成类,ns生成命名空间,main生成main函数,当然,这些都会用TAB键触发,因此之前YCM的配置中禁止了TAB补全防止与其冲突。

C++的snippets在~/.vim/plugged/vim-snippets/snippets/cpp.snippets文件定义,可以照着修改该文件来修改代码生成模板。

4.2 vim-clang-format

1
Plug 'rhysd/vim-clang-format'

之前安装llvm中自带了clang-formatvim-clang-format是对它的集成。无需配置,安装好后,vim命令模式下:ClangFormat便可对当前文件自动格式化。当然也可以nmap设置快捷键,不过我也习惯了输入:Cl+TAB键格式化当前代码。

代码格式化首先需要clang-format路径在环境变量PATH中(之前安装clang时已经配置过),然后从当前目录往上寻找.clang-format文件,我是直接用谷歌开源代码风格,比如我生成该文件在HOME目录下,命令如下:

1
clang-format --style=Google --dump-config >~/.clang-format

如果需要定制代码格式化文件,只需修改.clang-format即可,网上有各种资料。

4.3 C++头文件、源文件切换

1
Plug 'vim-scripts/a.vim'

开箱即用,据说是很早的插件了,:A命令切换头文件和源文件,比如xxx.hxxx.cc:AV:AS命令则是打开竖直、水平切分窗口来切换头文件和源文件。

4.4 asyncrun

1
Plug 'skywind3000/asyncrun.vim'

skywind大佬(知乎账号为韦易笑,vim大神)写的插件,我的vim配置也大部分是参考他的Vim 8下C/C++开发环境搭建。这里也基本照搬配置了,使用说明可参考他的博客:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
" 自动打开 quickfix window ,高度为 6
let g:asyncrun_open = 6

" 任务结束时候响铃提醒
let g:asyncrun_bell = 1

" 设置 F10 打开/关闭 Quickfix 窗口
nnoremap <F10> :call asyncrun#quickfix_toggle(6)<cr>

" 设置 F9 编译单个文件
nnoremap <silent> <F9> :AsyncRun g++ -std=c++11 -Wall -O2 "$(VIM_FILEPATH)" -o "$(VIM_FILEDIR)/$(VIM_FILENOEXT)" <cr>

" 递归查找包含该目录的目录作为根目录,若找不到则将文件所在目录作为当前目录
let g:asyncrun_rootmarks = ['.svn', '.git', '.root', '_darcs', 'build.xml']
" 设置 F7 编译项目
nnoremap <silent> <F7> :AsyncRun -cwd=<root> make <cr>

" 设置 F5 运行当前程序
"nnoremap <silent> <F5> :AsyncRun -raw -cwd=$(VIM_FILEDIR) "$(VIM_FILEDIR)/$(VIM_FILENOEXT)" <cr>
nnoremap <silent> <F5> :AsyncRun g++ -std=c++11 -Wall "$(VIM_FILEPATH)" && ./a.out <cr>"

由于vim 8才支持了异步执行终端命令,因此该插件需要vim版本至少为8。我的vim版本为8.1,还可以直接在vim中横向打开终端窗口(命令为terminal,默认横向打开,vert term纵向打开)。

4.5 LeaderF

1
Plug 'Yggdroot/LeaderF'

其配置照抄了skywind的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
"" LeaderF
" Ctrl+P在当前项目目录打开文件搜索, Ctrl+N打开MRU搜索,搜索最近打开的文件
" Alt+P打开函数搜索,Alt+N打开Buffer搜索
let g:Lf_ShortcutF = '<c-p>'
let g:Lf_ShortcutB = '<m-n>'
noremap <c-n> :LeaderfMru<cr>
noremap <m-p> :LeaderfFunction!<cr>
noremap <m-n> :LeaderfBuffer<cr>
noremap <m-m> :LeaderfTag<cr>
let g:Lf_StlSeparator = { 'left': '', 'right': '', 'font': '' }

let g:Lf_RootMarkers = ['.project', '.root', '.svn', '.git']
let g:Lf_WorkingDirectoryMode = 'Ac'
let g:Lf_WindowHeight = 0.30
let g:Lf_CacheDirectory = expand('~/.vim/cache')
let g:Lf_ShowRelativePath = 0
let g:Lf_HideHelp = 1
let g:Lf_StlColorscheme = 'powerline'
let g:Lf_PreviewResult = {'Function':0, 'BufTag':0}

各种Leaderf开头的命令,可以:LeaderfFunction查找函数、:LeaderfFile查找文件,等等。我用得不是很多,所以配置基本上也没用。

4.6 vim-signify

1
Plug 'mhinz/vim-signify'

配置仅需

1
set signcolumn=yes  " 强制显示侧边栏,防止时有时无

可以实时显示git项目的修改状态,如下图所示:

signify显示git项目修改情况

4.7 vim页面美化插件

1
2
3
4
5
Plug 'sickill/vim-monokai'               " monokai主题
Plug 'vim-airline/vim-airline' " 美化状态栏
Plug 'vim-airline/vim-airline-themes'
Plug 'plasticboy/vim-markdown' " markdown高亮
Plug 'octol/vim-cpp-enhanced-highlight' " C++代码高亮

配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
"" airline
let laststatus = 2
let g:airline_powerline_fonts = 1
let g:airline_theme = "dark"
let g:airline#extensions#tabline#enabled = 1

"" vim-monokai
colorscheme monokai

"" vim-markdown
" Github风格markdown语法
let g:vim_markdown_no_extensions_in_markdown = 1

"" vim-cpp-enhanced-highlight
let g:cpp_class_scope_highlight = 1
let g:cpp_member_variable_highlight = 1
let g:cpp_class_decl_highlight = 1
let g:cpp_experimental_template_highlight = 1

5. 我自己写的简单代码模板

之前简单学了下vimscripts,简单写了点代码模板,即新建.h.c.cc.cpp文件会自动生成相应代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
" 代码模板
autocmd BufNewFile *.cc,*.cpp,*.c exec ":call SetTitle()"
function SetTitle()
if &filetype == 'cpp'
call setline(1, "#include <iostream>")
call setline(2, "using namespace std;")
call setline(3, "")
call setline(4, "int main(int argc, char* argv[]) {")
call setline(5, " return 0;")
call setline(6, "}")
endif
if &filetype == 'c'
call setline(1, "#include <stdio.h>")
call setline(2, "#include <stdlib.h>")
call setline(3, "")
call setline(4, "int main(int argc, char* argv[]) {")
call setline(5, " return 0;")
call setline(6, "}")
endif
" 新建文件后自动定位到文件末尾
autocmd BufNewFile * normal G
endfunction

autocmd BufNewFile *.h exec ":call SetTitleForH()"
function SetTitleForH()
let name = toupper("".expand("%"))
let name = join(split(name, '\.'), '_')
let name = join(split(name, '-'), '_')
call setline(1, join(['#ifndef', name]))
call setline(2, join(['#define', name]))
call setline(3, join(['#endif //', name]))
exec ":2"
endfunction

6. 总结

本篇介绍了搭建C/C++开发环境中的一些插件,虽然对于一般人我可以算vim狂热者了,但是对于真正熟悉vim的人来说,我还有很多需要学习的,比如很多插件我也是浅尝辄止,但毕竟vim不同于IDE的一点就是其配置高度个人化,真正觉得需要提高效率了再去看看是不是需要详细了解下插件怎么用。

我的vim-plug管理的插件如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
call plug#begin('~/.vim/plugged')

Plug 'Valloric/YouCompleteMe'
Plug 'scrooloose/nerdtree', { 'on': 'NERDTreeToggle' }
Plug 'w0rp/ale'
Plug 'SirVer/ultisnips'
Plug 'honza/vim-snippets'
Plug 'rhysd/vim-clang-format'
Plug 'vim-scripts/a.vim'
Plug 'skywind3000/asyncrun.vim'
Plug 'Yggdroot/LeaderF'
Plug 'mhinz/vim-signify'
Plug 'sickill/vim-monokai' " monokai主题
Plug 'vim-airline/vim-airline' " 美化状态栏
Plug 'vim-airline/vim-airline-themes'
Plug 'plasticboy/vim-markdown' " markdown高亮
Plug 'octol/vim-cpp-enhanced-highlight' " C++代码高亮

call plug#end()

如果vim编辑比较卡的话,可以试着注释掉某些插件,比如我实测C++代码高亮有时候会拖慢速度。YCM如果配置于某些较为庞大的项目可能也有点问题。

~/.vim/plugged目录下各插件占用磁盘大小:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ du -h --max-depth=1 . | grep -v "\.$"
512K ./vim-clang-format
1.8M ./vim-snippets
240K ./a.vim
8.8M ./ultisnips
1.4M ./LeaderF
436K ./vim-cpp-enhanced-highlight
908K ./nerdtree
1.9M ./asyncrun.vim
341M ./YouCompleteMe
992K ./vim-markdown
2.3M ./vim-signify
820K ./vim-airline-themes
1.2M ./vim-airline
24M ./ale
224K ./vim-monokai

YCM目录包含了编译的文件,所以从200多M涨到了341M。

我的vim开发环境搭建(1): 准备工作

前言

即将毕业入职,之前实习时的开发机是CentOS 6.10,因此这里vim开发环境的搭建是基于CentOS 6.10的。

相比更流行的Ubuntu系统,CentOS的毛病实在太多了,折腾起来累死人。另一方面,由于公司开发机并不能像个人电脑一样随心所欲,所以像高版本的vim、gcc等,都是安装到自己的HOME目录,因此绝大多数软件需要手动下载源码、编译、安装到HOME目录。

问题来了,为什么要升级高版本?

  1. CentOS 6.10的源默认软件太老了,比如gcc版本才4.4.7,连C++11都不支持;
  2. 像YouCompleteMe(YCM)这种优秀的插件,需要各种高版本软件。

为了节约篇幅,后文的下载、解压的命令都省略了,比如下载基本就是找到官网下载链接,然后wget命令下载下来,对于github上的项目,直接git clone。有时候比较慢,我会在租的VPS上下载好,然后打包传回来。

使用tar解压,对.tar.gz后缀的用zxvf选项来解压,对.tar.bz后缀的用jxvf选项解压,对.tar.tar.xz后缀的用xvf选项解压。其中v选项不是必要的,只是查看解压了什么内容。

本人在折腾的过程中踩了很多坑,遇到很多错误,借助Google解决了大量问题,后文中不会对这些问题进行描述,而是直接讲述最终尝试后可行的方案。所有尝试均在VMware中新安装的CentOS 6.10系统上进行,系统来源于CentOS镜像站的bin-DVD1版本。

所有的软件均安装在$HOME/local下,我将其设为了环境变量LOCAL,通过在~/.bashrc中加上如下内容:

1
export LOCAL=$HOME/local

1. 升级gcc

这里选用了我一直用的gcc版本,也是Ubuntu 16.04默认安装的最新版本:5.4。其实只需要4.8就够了,即完整支持C++11的功能。

在CentOS下需要安装交叉编译IDE依赖,参考gcc installation error,我新安装的系统没有g++,因此还需要安装gcc-c++

1
sudo yum install -y glibc-devel.i686 libgcc.i686 gcc-c++

去官网给出的镜像列表中找到中国的地址,比如ustc镜像,下载gcc源码并解压,然后进入gcc源码目录,执行以下命令编译:

1
2
3
4
5
6
./contrib/download_prerequisites
mkdir build
cd build
../configure --prefix=$LOCAL/gcc-5.4.0 --enable-languages=c,c++
make -j4
make install

这里说明下,make选项是-j4是采用4个进程编译,因为我的个人电脑是4核。上述命令的逻辑就是新建build目录,然后configuremakemake install三步走的套路,通过--prefix选项指定install的目录。

修改~/.bashrc文件,设置环境变量来使本地的文件优先于全局的同名文件被选择:

1
2
3
export GCC_DIR=$LOCAL/gcc-5.4.0
export PATH=$GCC_DIR/bin:$PATH
export LD_LIBRARY_PATH=$GCC_DIR/lib:$GCC_DIR/lib64:$LD_LIBRARY_PATH

比如在我的电脑上这么配置后,gcc命令默认就是~/local/gcc-5.4.0/bin/gcc而非/usr/bin/gcc了。

2. 升级某些软件

有了高版本的gcc后便可从源码中直接编译安装高版本的软件了。本节升级的软件版本均已够用,如果需要更高版本的,可以选择下载更高版本的源码进行编译。

2.1 安装Python 2.7.16

去官网下载源码解压,进入目录。首先安装依赖项,然后编译、安装(其中test_weakref耗时比较久):

1
2
3
4
sudo yum install -y zlib zlib-devel openssl* bzip2*
./configure --prefix=$LOCAL/python2.7.16/ --enable-shared --with-zlib --enable-optimizations
make -j4
make install

~/.bashrc文件中添加环境变量:

1
2
3
export PY2_DIR=$LOCAL/python2.7.16
export PATH=$PY2_DIR/bin:$PATH
export LD_LIBRARY_PATH=$PY2_DIR/lib:$LD_LIBRARY_PATH

2.2 安装cmake 3.14.5

去官网下载源码解压,进入目录。直接编译、安装:

1
2
3
./configure --prefix=$LOCAL/cmake-3.14.5
gmake -j4
make install

这里的是gmake而非make,但在Linux上gmake只是指向make的符号链接,代表GNU Make。

~/.bashrc文件中添加环境变量:

1
2
export CMAKE_DIR=$LOCAL/cmake-3.14.5
export PATH=$CMAKE_DIR/bin:$PATH

2.3 安装vim 8.1

去github上克隆vim源码,进入目录,首先安装依赖项,然后编译安装:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ sudo yum -y groupinstall 'Development Tools'
$ sudo yum -y install ruby perl-devel python-devel ruby-devel perl-ExtUtils-Embed ncurses-devel
$ ./configure --prefix=$LOCAL/vim \
--with-features=huge \
--enable-multibyte \
--enable-pythoninterp=yes \
--with-python-config-dir=$PY2_DIR/lib \
--enable-luainterp=yes \
--enable-cscope
$ make VIMRUNTIMEDIR=$LOCAL/vim/share/vim/vim81
$ cd src
$ make
$ make install

~/.bashrc文件中添加环境变量:

1
2
export VIMPATH=$HOME/local/vim
export PATH=$VIMPATH/bin:$PATH

2.4 安装clang 8.0

LLVM下载页分别下载llvm、clang、clang-tools-extra、compiler-rt源码按照以下命令解压、重命名:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
wget http://releases.llvm.org/8.0.0/llvm-8.0.0.src.tar.xz
tar xvf llvm-8.0.0.src.tar.xz
mv llvm-8.0.0.src llvm
cd llvm/tools
wget http://releases.llvm.org/8.0.0/cfe-8.0.0.src.tar.xz
tar xvf cfe-8.0.0.src.tar.xz
mv cfe-8.0.0.src clang
cd clang/tools
wget http://releases.llvm.org/8.0.0/clang-tools-extra-8.0.0.src.tar.xz
tar xvf clang-tools-extra-8.0.0.src.tar.xz
mv clang-tools-extra-8.0.0.src extra
cd ../../../projects/
wget http://releases.llvm.org/8.0.0/compiler-rt-8.0.0.src.tar.xz
tar xvf compiler-rt-8.0.0.src.tar.xz
mv compiler-rt-8.0.0.src compiler-rt

最终层次结构为:

1
2
3
4
5
6
7
llvm
\-- tools
\-- clang
\-- tools
\-- extra
\-- projects
\-- compiler-rt

其中clang目录为重命名后的cfe目录,extra目录为重命名后的clang-tools-extra目录,并且都去掉了后缀版本号。

然后进入llvm目录,执行下列编译操作,注意cmake识别的gcc路径仍然是系统路径,因此需要手动指定CC和CXX路径

1
2
3
4
5
6
7
$ mkdir build
$ cd build
$ CC=$LOCAL/gcc-5.4.0/bin/gcc CXX=$LOCAL/gcc-5.4.0/bin/c++ \
cmake -G "Unix Makefiles" -DCMAKE_INSTALL_PREFIX=$HOME/local/clang8.0 \
-DCMAKE_BUILD_TYPE=Release -DLLVM_TARGETS_TO_BUILD="X86" ..
$ make -j4
$ make install

这里多线程编译可能会有问题,我用-j4编译到68%的ASTImportercc1plus直接崩了,报错如下:

1
2
[ 68%] Building CXX object tools/clang/lib/AST/CMakeFiles/clangAST.dir/ASTImporter.cpp.o
c++: internal compiler error: Killed (program cc1plus)

在.bashrc文件中添加环境变量

1
2
3
export CLANG_DIR=$LOCAL/clang8.0
export PATH=$CLANG_DIR/bin:$PATH
export LD_LIBRARY_PATH=$CLANG_DIR/lib:$LD_LIBRARY_PATH

3. 安装vim-plug管理插件

1
2
curl -fLo ~/.vim/autoload/plug.vim --create-dirs \
https://raw.githubusercontent.com/junegunn/vim-plug/master/plug.vim

之后编辑~/.vimrc便可添加管理的插件,其格式如下:

1
2
3
4
5
6
7
8
9
" vim插件下载目录
call plug#begin('~/.vim/plugged')

" vim插件列表,对应github项目地址
Plug 'tpope/vim-sensible'
Plug 'junegunn/seoul256.vim'

" 结束列表
call plug#end()

vim命令模式下:PlugInstall从github网址中克隆安装所有插件,:PlugClean卸载注释掉的插件列表。安装的插件会位于目录~/.vim/plugged下,如果要使用代理,可以手动克隆到该路径。

4. 插件无关的vimrc配置

将下列代码添加在~/.vimrc中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
" 定义<leader>为分号,此行代码必须在插件配置代码之前
let mapleader=";"

" 在处理未保存或只读文件的时候,弹出确认
set confirm
" 自适应不同语言的智能缩进
filetype indent on
" 设置编辑时制表符占用空格数
set tabstop=4
" 设置格式化时制表符占用空格数
set shiftwidth=4
" 将制表符扩展为空格
set expandtab
" 让 vim 把连续数量的空格视为一个制表符
"set softtabstop=4
" 显示行号
set number
"搜索忽略大小写
"set ignorecase
"搜索逐字符高亮
set hlsearch
set incsearch
" 使回格键(backspace)正常处理indent, eol, start等
set backspace=2
" 允许backspace和光标键跨越行边界
set whichwrap+=<,>,h,l
" 在被分割的窗口间显示空白,便于阅读
set fillchars=vert:\ ,stl:\ ,stlnc:\
" 高亮显示匹配的括号
set showmatch
" 匹配括号高亮的时间(单位是十分之一秒)
set matchtime=1
" 光标移动到buffer的顶部和底部时保持3行距离
set scrolloff=3

" 括号补全
inoremap ( ()<ESC>i
inoremap [ []<LEFT>
inoremap " ""<ESC>i
inoremap ' ''<ESC>i
inoremap { {}<ESC>i
inoremap {<CR> {<CR>}<ESC>O

function! ClosePair(char)
if getline('.')[col('.') - 1] == a:char
return "\<Right>"
else
return a:char
endif
endfunction

inoremap ) <c-r>=ClosePair(')')<CR>
inoremap } <c-r>=ClosePair('}')<CR>
inoremap ] <c-r>=ClosePair(']')<CR>

5. 总结

本篇博客主要完成了gcc、python、cmake、vim、clang的升级/安装,均采取了直接编译源码的方式,而像gcc和clang的源码编译非常耗时,python的源码编译虽然较快,但是test_weakref脚本的执行非常慢。

编译源码的方式较为简单,但有些依赖项必须安装,否则找解决方法非常麻烦。

最后安装了vim-plug作为插件管理器,并给出了插件无关的部分vimrc脚本。下一章节将着重介绍我个人常用的插件。

make_shared调用私有构造函数的解决方法

1. enable_from_shared

Effective Modern C++ 的条款19中提到了 shared_ptr 的问题,对于某个共享所有权的类(记为 Widget )的示例,可能会将引用对象分享给其他类,比如如下所示的观测者模式示例:

1
2
3
4
5
vector<shared_ptr<Widget>> observed_widgets;

void Widget::addToObserver() {
observed_widgets.emplace_back(this);
}

Widget 类的成员函数 addToObserver 将自身指针添加到全局的 Widget 列表 observed_widgets 中。

上述代码的问题在于, this 的类型是裸指针 Widget* ,在添加进 vector 中时,会用裸指针构造 shared_ptr 对象,创建一个新的引用计数。如果调用 addToObserver 的对象本身也是 shared_ptr ,会导致对同一对象有2个引用计数,因此该对象会被析构2次,产生未定义的行为。

解决方式是继承enable_shared_from_this<Widget>类(使用了C++的奇妙递归模板模式),像这样:

1
2
3
4
5
6
7
8
9
struct Widget;  // 前置声明
vector<shared_ptr<Widget>> observed_widgets;

class Widget : public enable_shared_from_this<Widget> {
public:
void addToObserver() {
observed_widgets.emplace_back(shared_from_this());
}
};

Widget 类从基类继承了 shared_from_this 方法,该方法会查询当前对象是否已经被 shared_ptr 引用了,如果有,则返回该 shared_ptr 对象而非裸指针 this 。

需要注意的是如果没有,会出现未定义行为,比如在我的系统上是抛出 std::back_weak_ptr 异常。因此良好的设计需要把 Widget 的构造函数私有化,并使用工厂方法来创建 shared_ptr<Widget> 对象,如下所示:

1
2
3
4
5
6
7
class Widget : public enable_shared_from_this<Widget> {
public:
// C++14语法,自动推导返回类型,参考Effective Modern C++条款2和3
static auto create() { return shared_ptr<Widget>(new Widget); }
private:
Widget() = default;
};

2. make_shared代替new与私有构造函数的冲突

Effective Modern C++ 的条款21阐述了 make_shared 的优点,这里不详述,简单总结就是:

  1. 更高的异常安全级别,防止构造 shared_ptr 之前就调用 new ,抛出异常;
  2. 仅分配1次内存来保存引用对象和控制块(两者内存分布是连续的);

缺点则是无法指定自定义析构器,再就是大括号初始化的歧义,但在这两种情况之外, make_shared 存在尺寸和速度上的优势,原则上是能使用 make_shared 则使用之。

Effective Modern C++ 给出了例外场景:

  1. 自定义内存管理的类: 需要传递自定义析构器, make_shared 无法使用;
  2. 内存紧张的系统或非常大的对象:由于内存碎片的问题,分配1次内存比分配2次内存更容易失败。

修改工厂方法如下所示(这里和后文均忽略基类和其他成员):

1
2
3
4
5
6
class Widget {
public:
static auto create() { return make_shared<Widget>(); }
private:
Widget() = default;
}

编译失败,错误提示如下:

1
2
3
4
5
error: ‘constexpr Widget::Widget()’ is private
Widget() = default;
^
/usr/include/c++/5/ext/new_allocator.h:120:4: error: within this context
{ ::new((void *)__p) _Up(std::forward<_Args>(__args)...); }

原因是 make_shared 函数模板并非 Widget 类的友元函数,其访问了私有构造函数。而静态成员函数可以访问类的私有成员(比如这里的私有构造函数),因此可以在 create 内部调用 new (两步:分配内存、调用构造函数)。

不知道是不是 C++ 在制定 make_shared 的标准时疏忽的一点,但是在保持可移植性的情况下,最简单的方法就是用 new 替代 make_shared ,而且仔细来看, make_shared 的性能优势可能并没那么重要,至于异常安全,大多数时候程序处理 new 抛出的异常就是任其终止。

说句题外话,这也是 C++er 经常背负的心智负担,由于 C++ 自身的一些缺陷,导致使用者经常纠结是否过早优化。但既然本篇讨论这个问题了,那就给出一些解决方式。

3. 解决方式

stackoverflow 上有相关讨论 ,本文对其进行一些整合,给出2种典型的方法。

3.1 使用公有构造的派生类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Widget {
public:
template <typename... Args>
static auto create(Args&&... args) {
struct EnableMakeShared : public Widget {
EnableMakeShared(Args&&... args) : Widget(std::forward<Args>(args)...) {}
};
return static_pointer_cast<Widget>(
make_shared<EnableMakeShared>(std::forward<Args>(args)...));
}

private:
Widget() = default;
// other constructors...
};

该方法通过原封不动地继承 Widget 类,并利用可变参数模板覆盖 Widget 类的构造函数。然后通过派生类的 shared_ptr 向上转型为基类的 shared_ptr 。

这个方法的巧妙之处在于局部类(local class)的使用,如果是在类的外部定义其派生类,则必须将基类构造函数权限改成 protected 才能访问。

局部类只能访问外层作用域定义的类型名、静态变量以及枚举成员,而不能访问普通局部变量。但是类成员函数的局部类对类的私有成员的访问权限在书中并未提及我,网上搜到的一些资料也没找到,只有查看接近1400多页的C++标准文档,在 9.8 Local class declaration 中找到了如下定义:

The local class is in the scope of the enclosing scope, and has the same access to names outside the function as does the enclosing function.

局部类和所在的函数对函数外的名称具有相同的访问权限,因此类成员函数的局部类可以访问该类的所有成员,包括私有构造函数。

最后一个问题是使用 Widget 的派生类构造派生类,是否需要将 Widget 的析构函数声明为虚函数?这里是不需要的,因为派生类并未额外申请任何资源,因此不执行派生类的析构函数也是没有问题的。

3.2 使用PassKey惯用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Widget {
private:
struct PassKey {
explicit PassKey() {}
};

public:
template <typename... Args>
explicit Widget(PassKey, Args&&... args)
: Widget(std::forward<Args>(args)...) {}

template <typename... Args>
static auto create(Args&&... args) {
return make_shared<Widget>(PassKey{}, std::forward<Args>(args)...);
}

private:
Widget() = default;
// other constructors...
};

PassKey惯用法的原理是通过给公有构造函数增加一个无意义的嵌套类(在这里即 PassKey 类),将其定义在类的私有域,在构造函数中使用嵌套类作为第1个参数,这样就无法从类的外部使用嵌套类的名字,因此无法调用这些构造函数。而类的静态成员函数可以访问嵌套类的名字,因此可以调用构造函数。

但这里有个细节, PassKey 的构造函数是 explicit 的,否则便可以在类外部传入 {} 作为构造函数的第1个参数,隐式转换成 PassKey 类。

另外还有个细节,PassKey 的构造函数不可以写成explicit PassKey() = default,否则在间接调用带实际参数的私有构造函数时会出错。比如,假设 Widget 类有如下私有构造函数:

1
2
Widget() {}
Widget(const char*) {}

调用及其编译结果如下:

1
2
Widget w1;  // 错误! Widget()是私有的!
Widget w2({}, "hello"); // 通过编译

但如果把 PassKey 类的构造函数像之前那样显式定义,而不是通过 =default 使用编译器自动合成的构造函数,上述代码中 w2 的构造就无法通过编译。
至于原因暂时还没弄懂。

4. 总结

前文提到的2种方法都是用了一些比较冷门的语法,但是用起来还是比较麻烦且不直观。优雅的解决方案可能需要等待新的C++标准,比如Andrew Schepler的C++标准提案
虽然等到新标准估计要到猴年马月了,总之C++11的一些新东西虽然好用,但是也留了不少坑,这点还是挺让人不愉快的,研究这些东西其实实质上也没还什么意义,自娱自乐罢了。

socket迭代型服务器中Ctrl+C安全退出

前言

最近抽空在重新造网络库轮子,之前的socket_util太过简陋,就是把socket地址、socket API、epoll和sendallrecvn之类的函数给封装下,发现自己的基础知识还是欠缺,于是边重做轮子边补充知识。
于是在做最基本的迭代型服务器时遇到了问题,就是Ctrl+C退出循环失败,这里记录下解决方案。

1. 信号中断后自动重启的系统调用

最初的解决思路如下

1
2
3
4
5
6
7
8
9
10
::signal(SIGINT, [](int) {});
while (true) {
// ...
int ret = some_system_call(); // 代替某些自动重启的系统调用
if (ret == -1) {
if (errno == EINTR)
break;
// else ...
}
}

思路就是对于某些被信号中断后会自动重启的系统调用,检查返回值,为-1代表返回错误,然后检查errno,为EINTR就相当于被信号中断,此时跳出循环。
Linux上某些系统调用会导致阻塞,比如某些慢速I/O操作(readwriterecvsend),当然也包括socket的accept这种可以触发epoll监听事件的(在我看来算广义的I/O操作)。如果对某个信号使用sigaction设置过信号处理器,并且时sa_flags字段包含SA_RESTART,那么这些系统调用在被该信号中断时会自动重启,避免了对errno的检查(像下面这样的代码)

1
2
int ret;
while ((ret = some_system_call()) == -1 && errno == EINTR) continue;

其中,sigaction使用示例如下

1
2
3
4
5
6
struct sigaction act;
act.sa_handler = [](int) {}; // 信号处理器
act.sa_flags = SA_RESTART; // 设置重启标志
sigemptyset(&act.sa_mask);

sigaction(SIGINT, &act, nullptr); // 使信号处理器生效

当然,这种自动重启的功能仅对特定的系统调用有效,可以参考The Linux Programming Interface的21.5节,个人简单总结如下:

  • socket的阻塞操作(acceptconnectrecvsend等);
  • 进程间IPC的阻塞操作(管道、FIFO、POSIX消息队列、信号量、文件锁);
  • 线程同步的阻塞操作(条件变量的等待)
  • 终端的阻塞操作(比如向STDOUT_FILENO写或从STDIN_FILENO读);
  • wait系列,等待子进程终止;
  • 可能会阻塞的open
  • futex(Linux独有);

而像epoll这种I/O多路复用的,即使指定SA_RESTART也不会自动重启。其实从设计的出发点看很好理解。

2. 细节上的修改

最初的解决方案在我实际应用中遇到了一些问题,比如这个循环是封装在我的库函数中的,而我需要传递一个回调函数给这个库函数来处理表示连接的socket用于读写。
这样问题来了,我在回调函数中如果是while循环来recv,那么在回调函数中是无法直接跳出所在函数的循环的,比如我的函数可能是这样

1
2
3
4
5
6
7
8
9
10
void runIterativeServer(std::function<void(int)> callback, ...) {
// 1. 初始化socket相关操作...
// 2. 为SIGINT设置信号处理器
while (true) {
// 3. accept得到connfd,处理errno为EINTR的情况
callback(connfd); // 4. 执行回调函数
// 5. 关闭connfd
}
// 6. 清理操作
}

解决方法很简单,处理EINTRcontinue即可,用信号处理器来退出while循环

1
2
3
4
5
6
7
8
9
10
11
12
static volatile bool run = false;
::signal(SIGINT, [](int) { run = false; });
run = true;
while (run) {
// ...
int ret = some_system_call(); // 代替某些自动重启的系统调用
if (ret == -1) {
if (errno == EINTR)
break;
// else ...
}
}

注意细节,staticvolatile
使用static是因为信号处理器只能接受void (*)(int)这样的函数指针,不带捕获的lambda表达式可以与之兼容,但是带捕获的lambda表达式不行。本质上就是信号处理器只能处理全局变量。静态变量和全局变量本质是一样的,只是方便命名信息隔离,所以这里用函数内的静态变量。
使用volatile则是因为,开优化选项时编译器可能做某些优化来把run放到寄存器中读取而不是直接从内存读取,这样就会导致信号处理器的修改并不会被看到,从而while循环仍然判定为true,之前写过相关博客C/C++的volatile关键字应用示例

这样一来在回调函数中就可以写出这样的代码了

1
2
3
4
5
6
char buf[1024];
while (true) {
ssize_t num_recv = recv(connfd, buf, sizeof(buf), MSG_NOSIGNAL);
if (num_recv == -1 && errno == EINTR) break;
// ...
}

到这里其实还有个很关键的问题,也是很容易被忽视的。其实无论是这个版本还是之前的版本,都是无效的,因为系统调用还是会自动重启,语句根本执行不到检查errno那一步。
原因是Linuxsignal函数,默认是调用sigaction,并且将sa_flags设置为SA_RESTART,从而支持某些系统调用自动重启。在其他系统上,signal也可能具有不同的语义,因此必须手动调用sigaction来代替signal

1
2
3
struct sigaction act;
act.sa_flags = 0; // 禁止系统调用自动重启
// ...

参考Unix环境高级编程的10.14节。
最后,可以用sigaction取得之前的信号处理器,然后在函数结束时恢复SIGINT的默认处置。

1
2
3
4
5
struct sigaction act, oldact;
// TODO: 设置act的字段
sigaction(SIGINT, &act, &oldact);
// ...
sigaction(SIGINT, &oldact, nullptr);

C++11中std::thread和pthread混用的坑

前言

早在之前我就因为混用std::threadpthread出现bug时纠结过,当时还去查看过std::thread的实现源码,但后来还是没得出结果。现在看来主要原因是我没有找对位置,被C++又臭又长的模板元编程手法给转移了注意力。直到最近我把这个实现弄清楚后,自己造轮子时遇到了另一个bug,才回过头想起之前遇到的问题。

1. 在线程函数中调用pthread_exitpthread_join

在C++11中,单靠thread是无法取得线程函数的返回值的,必须得借助async等异步设施,*Effective Modern C++*也推崇使用基于任务的并发编程而非基于线程。当然这个是后话了,
现在,我就是想直接取得线程函数的返回值,又不继续写pthread那套麻烦的线程函数,那么直接在std::thread的线程函数中调用pthread API?于是有了下面这段代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
// test.cc
#include <pthread.h>
#include <stdio.h>
#include <thread>

int main() {
// NOTE: make sure cast between long and void* is safe.
std::thread t([] { pthread_exit(reinterpret_cast<void*>(1)); });
void* ret;
pthread_join(t.native_handle(), &ret);
printf("thread exit with %ld\n", reinterpret_cast<long>(ret));
return 0;
}

编译运行,结果如下

1
2
3
4
# g++ -std=c++11 test.cc -pthread && ./a.out                                                                                                              
thread exit with 1
terminate called without an active exception
Aborted

嗯?是不是还要调用t.join()?于是我加上了t.join()

1
2
3
4
thread exit with 1
terminate called after throwing an instance of 'std::system_error'
what(): No such process
Aborted

这次是直接抛出异常了。好像都不行啊,那有没有解决方法呢?
在此之前,首先得明确一个问题,这里究竟该不该调用t.join()

2. std::terminate()

std::terminate()在头文件<exception>中声明,函数签名如下

1
[[noreturn]] void terminate() noexcept;

功能是调用当前的terminate handler,可以用set_terminate()来指定。

1
2
typedef void (*terminate_handler)();
terminate_handler set_terminate(terminate_handler f) noexcept;

对于没有被catch的异常,会默认调用terminate(),而terminate()会默认调用abort()。当然这三者并非等价,因为前两者还会打印出各自的信息,比如我们给出这三种调用的输出。

1
2
3
throw std::exception();  // 1
std::terminate(); // 2
abort(); // 3

分别运行这三条语句,均会使进程异常终止(比如在我的系统上echo $?得到结果是134)
语句1的输出是

1
2
3
terminate called after throwing an instance of 'std::exception'
what(): std::exception
Aborted

语句2的输出是

1
2
terminate called without an active exception
Aborted

语句3的输出是

1
Aborted

从上面3种输出的区分可以看出,异常默认处理器、std::terminate()都会打印出自己的信息,然后再递归调用下一步。
std::terminate()的主要用途是在禁止使用异常的C++项目中进行调用。
再回过头看第1节的运行结果,不难得出结论,调用t.join()会抛出异常,而不调用则仅仅是触发std::terminate(),检查t.joinable()的返回值,是true,但是t.join()却抛出了异常,原因当然是pthread_join()改变了std::thread的底层线程句柄,但是并没有将std::thread对象内部的句柄置为初始状态,导致std::thread误认为该线程还没有被join()

3. 一窥std::thread实现

很遗憾,<thread.h>头文件中没有包含一些关键的函数的实现,所以得自己去下载libstdc++源码,该源码是gcc的一部分。

1
git clone https://github.com/gcc-mirror/gcc.git

就我当前的版本(commit 0b47d0),std::thread的实现在这两个文件中

1
2
libstdc++-v3/include/std/thread
libstdc++-v3/src/c++11/thread.cc

查看thread.ccjoin()方法的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
void
thread::join()
{
int __e = EINVAL;

if (_M_id != id())
__e = __gthread_join(_M_id._M_thread, 0);

if (__e)
__throw_system_error(__e);

_M_id = id();
}

以及threadjoinable()方法的实现

1
2
3
bool
joinable() const noexcept
{ return !(_M_id == id()); }

其中_M_id定义如下(// ...是我自己添加的注释,代表省略了其他代码)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
 /// thread
class thread
{
public:
typedef __gthread_t native_handle_type;

~thread()
{
if (joinable())
std::terminate();
}

/// thread::id
class id
{
native_handle_type _M_thread;
// ...
};
// ...
private:
id _M_id;
};

std::thread本身只有1个成员变量_M_id,类型是std::thread::id,而id内部也只有1个成员变量_M_thread,类型是std::thread::native_handle_type,而这个类型标准没有给出规定,由不同操作系统和编译器来决定,显然gcc的实现是__gthread_t,而且在join()方法中是将该类型的变量传入了__gthread_join中。
简化上面的代码,也就是说实际上std::threadjoin是类似这样的

1
2
3
4
5
6
7
8
9
10
__gthread_t t;

void myjoin() {
int e = EINVAL;
if (t != __gthread_t())
e = __gthread_join(t, 0);
if (e)
throw std::system_error(e);
t = __gthread_t();
}

把这里的__g前缀替换成p的话,相当于就是调用pthread_join()后,重置线程句柄的值为pthread_t的默认值,然后用句柄是否等于pthread_t的默认值来判断是否以及调用了join()
而对于同一个句柄,重复调用pthread_join()会导致返回非0的错误码e,并抛出std::system_error异常。
因此,像我在第1节中做的,手动取得native_handle,然后手动调用pthread_join(),就会导致std::thread内部的线程句柄没有重置,进而joinable()返回了不应该返回的true,进而在析构函数中调用了join()。注意,C++析构函数是禁止抛出异常的,在C++11中析构函数默认为noexcept(true),也就是说直接调用std::terminate()终止程序。
这也解释了第1节中两种不同输出的原因,简单总结如下

  1. 不调用t.join(): t内部的线程句柄没有重置,析构函数判断joinable()为true,从而调用t.join(),抛出异常不被捕获,直接调用std::terminate()终止进程;
  2. 调用t.join(): t内部的线程句柄没有重置,调用t.join(),抛出异常,执行默认异常处理流程。

PS: 为何析构函数禁止抛出异常,以及noexcept的概念,分别参考*Effective C++Effective Modern C++*的相关条款。
哦对了,刚才分析的前提是,__g前缀可以被替换成p,为何可以呢?原因是gcc实际上也是跨平台的,因此会定义一组别名来屏蔽系统API的差异。由于Linux是遵守POSIX接口的,所以这组定义在gcc项目的libgcc/gthr-posix.h中,在该头文件中可以看到下列定义

1
2
3
4
typedef pthread_t __gthread_t;
/* Typically, __gthrw_foo is a weak reference to symbol foo. */
#define __gthrw(name) __gthrw2(__gthrw_ ## name,name,name)
__gthrw(pthread_join)

类型别名可以直接typedef,函数名称就得改变符号表了,这里我就不给出内部的__attribute____weakref__等具体实现手段了。

4. 不可移植的混合调用方法

有了上述的源码分析后,实际上在pthread_join()之后要做的,仅仅是将std::thread的内部线程句柄重置就完了。不过源码直接将其用private保护,所以得强行做指针转换来修改。

1
*reinterpret_cast<pthread_t*>(&t) = pthread_t();

将这一句代码添加到pthread_join()之后即可。不过本身不可移植指的是这种做法是无法在不同编译器之间移植的,如果指定了编译器使用gcc版本,像这样hack下也未尝不可。
给个示例,线程函数中new一个int,然后pthread_join捕获该指针来处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <stdio.h>
#include <assert.h>
#include <pthread.h>
#include <thread>
#include <memory>

void taskNewInt(int x) {
pthread_exit(new int(x));
}

int main() {
std::thread t(taskNewInt, 47);
void* ret;
int error = pthread_join(t.native_handle(), &ret);
assert(error == 0);

printf("thread joinable? %s\n", t.joinable() ? "Yes" : "No");

*reinterpret_cast<pthread_t*>(&t) = pthread_t();
printf("thread joinable? %s\n", t.joinable() ? "Yes" : "No");

std::unique_ptr<int> p(static_cast<int*>(ret)); // RAII
printf("thread exit with %d (%p)\n", *p, p.get());
return 0;
}

编译运行结果

1
2
3
4
5
$ g++ -std=c++11 native_thread_exit.cc -pthread
$ ./a.out
thread joinable? Yes
thread joinable? No
thread exit with 47 (0x7fd8640008c0)

5. pthread_exitpthread_cancel会抛出异常

对,你没看错,这两个C接口,会抛出异常。我之前在造线程轮子的时候,在某个调用pthread_exit()的包装函数声明为noexcept,结果被强制调用std::terminate()了。后来发现,原因是pthread_exit抛出了异常,而noexcept函数中抛出异常不会被捕获,只是简单调用std::terminate()了事。
嗯?问题来了,那这个异常又是怎么处理的呢?
异常的实现我没有仔细研究过,但大体上和setjmplongjmp类似,让函数调用栈回绕(unwind)。只不过异常支持多层回绕,因此可以层层传播异常,把底层异常一步步抛到最上层一并处理,从而将错误处理和功能实现相分离。
C++和C虽然都是用同一套头文件,但是编译C++时链接到的动态库是libstdc++.so而非libc.so,其中用抛出abi::__forced_unwind异常的方式来简化这个回绕的实现,该异常的默认处理方式就是进行线程栈回绕。包含<cxxabi.h>头文件即可捕获该异常,当然,注意catch处理之后要重新throw抛出异常。
这个问题在stackoverflow上有所讨论,参考why does pthread_exit throw something caught by ellipsis

lambda捕获this指针

最近看某qt项目,看到connect使用了lambda表达式,比如

1
2
3
4
5
6
7
void MainWindow::initOutput() {
// ...
connect(ui->tableViewOutput, &QWidget::customContextMenuRequested, this, [=](const QPoint &pos) {
m_outputContextMenu.exec(ui->tableViewOutput->mapToGlobal(pos));
});
// ...
}

命名规范是m_开头的是成员变量,可以发现类成员函数中用lambda表达式中直接使用了成员变量。
于是我突然有了疑问:

  1. 成员变量不是局部变量,那是怎么捕获的呢?
  2. 如果含有不可拷贝的成员变量,那么=岂不是失效了?

为了解决这些疑问,一步步用代码验证,首先测试不可拷贝的成员变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>
#include <memory>
using namespace std;

struct Foo {
std::unique_ptr<int> p;

void f() {
auto f = [=] { cout << p.get() << endl; };
f();
}
};

int main() {
Foo foo;
foo.f();
}

上述代码运行正确,打印0。而unique_ptr是典型的不可拷贝的类,用=却捕获成功了。
怀着疑问,我尝试了下局部变量,把类的定义改成下面这样

1
2
3
4
5
6
7
struct Foo {
void f() {
std::unique_ptr<int> p;
auto f = [=] { cout << p.get() << endl; };
f();
}
};

编译出错(嗯,其实在编译前,我的vim插件ale已经提示了错误)

1
use of deleted function 'std::unique_ptr<_Tp, _Dp>::unique_ptr(const std::unique_ptr<_Tp, _Dp>&) [with _Tp = int; _Dp = std::default_delete<int>]

那么,也就是说,类成员函数中的lambda表达式并不是像捕获局部变量一样”捕获”类成员变量,而是通过某些其他途径得以访问类成员变量。
参阅*Effective Modern C++*后面的部分(嗯,我从前往后抽空看的,目前还没看完),恍然大悟。
条款31:避免默认捕获模式中,书上举出了一个类似例子,并给出了说明

捕获只能针对于在创建lambda式的作用域内可见的非静态局部变量(包括形参)。

也就是说,在函数体外的类成员变量(这里的p)是无法被捕获的,那么为何可以直接访问p呢?
其实是捕获了this指针。

每一个非静态成员函数都持有一个this指针,然后每当提及该类的成员变量时都会用到这个指针。

那么这里也解释通了,其实[=]捕获了this指针,然后编译器看到p.get()时,把它翻译成了this->p.get()。把代码中的[=]改成[this],仍然成功编译。
也就是说,如果this指向的对象已经被析构,捕获this指针的lambda式就可能出现问题,比如下面代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <functional>
#include <iostream>
#include <memory>
using namespace std;

struct Foo {
std::unique_ptr<int> p;
std::function<void()> f() {
p.reset(new int(1));
return [=] { cout << *p << endl; };
}
};

int main() {
auto foo = new Foo();
auto f = foo->f();
delete foo;
f();
}

运行结果为0而非1,而且这里输出0是未定义行为,因为访问的实际上是被回收的空间,只是因为编译器的delete并没有对回收的空间做额外的操作,所以p指向的仍然是原来那块,只不过那块已经被unique_ptr的析构函数自动清除了,只不过将清除的部分全部置为0而已。

由于[=]很容易让人忽略掉this也被捕获了,所以很容易让人忽视这个问题,所以不如[this]直观。

实现thread库细节:使用std::decay保存函数指针

0. 前言

最近想包装pthread,像C++11线程库一样,毕竟标准库对于很多东西都没有包装,比如线程属性的设置。虽然可以用thread::native_handle_type()取得底层的pthread_t句柄,当然,本身就没想去麻烦地跨平台。
主要目的还是出于学习,以及写个顺手的线程库。
参考了知乎上的一篇回答:C++11是如何封装thread库的
花了不少精力终于理解了整套流程,期间通过《C++ Primer》第16章复习了完美转发、可变模板参数(解包、包扩展)的知识,通过qicosmos前辈的博客泛化之美–C++11可变模板参数的妙用补充了一些知识。
在做了一些简单的测试后,便开始建立一个仓库实现我的想法。
但是自己实现过程中也遇到一些细节问题,毕竟回答也不可能太详细,比如这篇提到的decay

1. decay类型退化

C++ Primer中提到,在模板类型推导中,一般的类型转换是禁止的,否则无法准确推断是哪种类型。但是两种类型转换是允许的:

  1. 将非const的引用或指针传递给const的引用或指针形参,比如
    1
    2
    3
    4
    5
    template <typename T> void f(const T&);  // 函数模板声明

    int i = 0;
    int& ri = i;
    f(ri); // ri被转换成const int&类型,因此T被推断为int
  2. 若形参不是引用类型,数组实参会被转换成指针形参,函数实参会被转换成函数指针形参,比如
    1
    2
    3
    4
    5
    6
    7
    template <typename T> void f(T param);

    void func();
    f(func); // T被推断为void(*)()

    int a[10];
    f(a); // T被推断为int*

decay完成的功能即将数组和函数转换成退化后的类型,对于其他类型,则是移除引用以及constvolatile描述符,分别对应上述的2和1。
注意,在模板类型推断中,实参的引用类型都会被忽略,就像上述1中的const被忽略一样。
比如传入int&到形参T t中,推断方式是:先忽略&,然后匹配intT,因此T永远不会被推断为引用类型,除非形参是右值引用T&& t,根据引用折叠规则(&& &&折叠为&&&& && &&& &被折叠为&),才有可能推断T为引用,这也是C++11实现完美转发的基础。
从类型T到退化类型typename std::decay<T>::type的映射示例如下

1
2
3
4
5
6
7
8
int () => int (*)()
int (&)() => int (*)()
int [10] => int*
int (&) [10] => int*
int const => int
int const& => int
int const* => int const*
int* const => int*

2. 使用可变模板参数构造线程函数的简单原理

C++11线程的构造函数是

1
2
template <typename F, typename... Args>
explicit thread(F&&, Args&&...);

而底层线程接口是C风格的,一般需要将参数类型和void*进行转换,比如对于pthread线程函数,一般像这样使用

1
2
3
4
5
void* threadFunc(void* arg) {
auto input = static_cast<input_type*>(arg);
// do sth... and generate an exit_code
return reinterpret_cast<void*>(exit_code);
}

当然,返回值也可以是动态分配的对象的指针,但这就需要显式pthread_join,处理完返回类型后将该指针指向的资源回收。
实现C++11风格的线程函数的思路是:用std::tuple<Args...>(记为TupleType)将整个打包,然后动态申请一个TupleType*指针传入C风格线程函数。
由于线程函数是编译时期就决定的,无法像lambda表达式一样捕获外部参数,所以需要用虚函数实现:
C风格线程函数中将void*转换成Base*,而继承自Base类的Derived则实现可变模板参数的构造函数,将函数和输入参数打包,然后override基类的虚函数void invoke(),调用打包的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct BaseData {
virtual ~BaseData();
virtual void invoke();
};

template <typename F, typename... Args>
struct DerivedData; // 省略具体实现,内部包含一个tuple打包数据,实现了invoke函数调用函数

void threadFunc(void* args) {
auto p = static_cast<BaseData*>(args);
p->invoke();
}

// 线程函数的实现
template <typename F, typename... Args>
void createThread(F&& f, Args&&... args) {
auto data = new DerivedData(std::forward<F>(f), std::forward<Args>(args)...);
pthread_t tid;
int error = pthread_create(&tid, nullptr, threadFunc, static_cast<void*>(data));
// handle error
}

其中有一些细节,比如如何在编译期确定get<i>()i的范围,需要一点模板元的技巧,在C++17中index_sequenceinvoke均已实现,C++11中也可以照着libstdc++源码自行实现。
另外为了线程安全,可以使用std::unique_ptr代替new,构造对象时使用unique_ptr,然后调用release()方法传递内部指针,再在线程函数中构造unique_ptr
不过本文重点说的就是怎么打包。

3. 打包退化类型的数据

看似打包数据应该使用完美转发,保留参数的cv修饰符以及引用类型,其实并不然,这点在本文链接的知乎回答中没有提及,毕竟是个细节,但是贴出的代码中都会发现,使用了decay

3.1 result_of的使用

对于底层C风格接口,返回的void*并不一定是实际你想返回的数据,因为线程函数往往只是完成一个任务,对主线程报告一个状态码。
但有时候也需要取得线程的计算结果,比如对一堆数据,分块并行求和,需要取得每个线程计算的结果,然后主线程将其相加。
C++11线程设施中,thread本身无法取得线程函数的返回值,需要结合future来完成,当然,在底层需要知道函数的返回类型,这里可以用<type_traits>中的result_of来实现。

1
2
using F = int& (*)(int, int);  // 函数指针
using R = std::result_of(F(int, int))::type; // int&

但是result_of有个陷阱,比如把上述代码在实际中可能是这个样子

1
2
3
4
5
6
int& f(int x, int y) {
// ...
}

using F = decltype(f);
using R = std::result_of<F(int, int)>::type;

然后编译就会出错

1
error:  type name’ declared as function returning a function

欲分析其原因,首先得对比函数和函数指针的类型表示:

表示 说明
F (Args...) 返回类型为F,输入参数为Args...的函数
F (*)(Args...) 返回类型为F,输入参数为Args...的函数指针
F (&)(Args...) 返回类型为F,输入参数为Args...的函数引用

注意函数的表示类型不是F()(Args...),中间的括号不需要,在指针和引用中加上这个括号只是为了区分是返回类型为F的函数指针/引用还是返回类型为F*/F&的函数。
分析前面的代码,Fdecltype(f)的类型是int& (int, int),那么F(int, int)int& (int, int)(int, int),返回类型为int& (int, int),参数为int, int
这里的返回类型是一个返回类型为int&、参数为int, int的函数,而函数本身只有退化为指针才能作为返回值,导致编译出错。

因此如果要将函数f和参数args...打包,如果完美转发f,那么可能保存的类型是函数而非函数指针、引用,导致调用result_of时编译出错。

3.2 能够被退化的类型本身无法进行拷贝

其实使用decay来打包的根本原因并不是result_of,毕竟,完全可以在invoke()方法的实现中再去把函数类型转换成函数指针。
初学C语言时,无论是书籍还是老师都会讲到,数组不能拷贝

1
2
int a[2], b[2];
a = b; // 编译错误

正因为如此,传入形参时才将其退化成指向数组首地址的指针。而C语言中不存在这么复杂的类型推导以及引用类型,根本无需区分函数和函数指针,所以没有强调函数退化成函数指针这个例子。但是,函数和数组一样,都是无法拷贝的。
而引用类型,看似可以拷贝

1
2
3
int i, j;
int &ri = i, &rj = j;
ri = rj; // 编译通过

实际拷贝的并不是引用本身,而是引用的对象之间进行拷贝。
那么,考虑数组进行打包,tuple的模板参数是无法设为不可拷贝的类型的,因此只能存为数组指针。
但是问题在于到底存放退化后的int*还是指向数组的指针int (*)[N]呢?参考如下代码

1
2
3
4
5
int a[3] = {0, 1, 2};
std::tuple<int*> t1(a);
cout << get<0>(t1)[2] << endl; // 2
std::tuple<int(*)[3]> t2(&a);
cout << get<0>(t2)[2] << endl; // 某个地址,比如0x7ffc462e1a98

只有退化类型才能表达出和原来传入实参一样的结果,就像完美转发一样。而另一种所谓的类型退化,即int(*)[N],实际上已经改变了实参的意义。
引用类型int(&)[N]能够和实参一样,但是问题是引用本身无法拷贝。
至于丢失的信息,比如数组丢失的长度信息,在函数调用中如果不是传入数组引用,本身就会丢失。既然参数转发前后都丢失了同样的信息,某种程度上也可以称为 完美转发 了。

3.3 打包数据的实现

类型退化可以看做是直接匹配,是很自然而然的操作,因此仅需把tuple模板参数声明为退化类型即可,传参时还是完美转发,实现如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template <typename T>
using decay_t = typename std::decay<T>::type;

template <typename F, typename... Args>
struct DataTuple {
std::tuple<decay_t<F>, decay_t<Args>...> t_;

DataTuple(F&& f, Args&&... args)
: t_(std::forward<F>(f), std::forward<Args>(args)...) {}
};

template <typename F, typename... Args>
DataTuple<F, Args...>* makeDataTuple(F&& f, Args&&... args) {
return new DataTuple<F, Args...>(std::forward<F>(f),
std::forward<Args>(args)...);
}

到这里,其实还有个关键问题,那就是,既然引用类型会丢失,那么传入引用类型的话,不就无法转发了吗?C++11线程库也考虑到了这点,因此使用了ref来对引用类型进行包装,这里给出一个机遇我们的DataTuple的示例

1
2
3
4
5
6
7
8
9
10
void f(int& x) { x++; }

int main() {
int i = 0;
int& ri = i;
auto p = makeDataTuple(f, std::ref(ri));
get<0>(p->t_)(get<1>(p->t_));
cout << i << endl; // 1
// delete p and exit.
}

输出结果和注释一样,是1,说明引用成功传递了,其实内部实现是一个reference_wrapper<T>,其内部保存一个T*,从而使得ref(ri)具有值语义。

gdb调试类模板成员函数

gdb常用命令简单回顾

首先列出一些常用的gdb命令
llist,从第1行开始查看当前文件,后面可接数字,比如l 10就是查看以第10行为中间行的若干行(比如第5-14行)。
bbreak,后接数字n,则代表在第n行设置断点。另外后接if <condition>可以设条件断点。
iinfo,后接b可以查看所有的断点信息(包括断点编号)。
ddelete,后接断点编号,可以删除特定断点。
rrun,运行程序。如果需要添加命令行参数,则后接命令行参数即可,也可以后接< file这种将文件file的内容重定向到标准输入。
nnext,运行1步。
sstep,进入这一行的函数中。

step的局限性

上面几个命令算是调试的通用命令,比较值得注意的是最后一个step操作,因为有可能一行会调用多个函数,比如下一行代码

1
vector<string> v = {"hello", "world"};

在这一行设断点,运行至此处,然后输入s

1
2
3
4
5
Breakpoint 1, main () at gdb.cc:8
8 vector<string> v = {"hello", "world"};
(gdb) s
std::allocator<std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >::allocator (this=0x7fffffffe22f) at /usr/include/c++/5/bits/allocator.h:113
113 allocator() throw() { }

简单看,这一句涉及到下面几个操作:

  1. 构造std::initialize_list<string>对象,包含2个字符串"hello""world"
  2. 调用vector<string>的构造函数,接收初始化列表;

但实际并不然,string对象会调用接收字符串字面值的构造函数,range for语法会调用到底层的begin()size()end()等方法,以及distance()函数之类,如果继续s下去,会看到大量无用信息。
因此需要在合适的时机用n跳到下一句,此时可以用l查看当前跳转到哪个文件了。比如我继续step两次跳转到了vector的构造函数

1
2
3
(gdb) s
std::vector<std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >, std::allocator<std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > > >::vector (this=0x7fffffffe230, __l=..., __a=...) at /usr/include/c++/5/bits/stl_vector.h:373
373 vector(initializer_list<value_type> __l,

但这是盲目的,因为我不清楚要跳转几次,如果提前next进入下一步了,可能就把后面整个过程就跳出去了。
因此如果想调试vector的构造函数而不去管其他的杂七杂八的函数,需要精确跳转到特定函数中。

gdb函数或文件设断点

gdb可以给文件设断点,比如我的程序是Blob.cc编译的,包含了自定义的头文件Blob.h
b <file>:<number>即可设断点,如下所示

1
2
(gdb) b Blob.h:25
Breakpoint 1 at 0x401da0: file Blob.h, line 25.

但问题是,怎么在gdb中查看Blob.h的内容以知道该在哪设断点?gdb不提供查看文件的命令,但是可以查看函数和类。
但对于类模板和函数模板而言,由于它们都是在编译时进行实例化的,所以需要提供模板参数。
且对于类成员函数而言,需要用::来指定对应成员函数。调试流程如下所示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
(gdb) l BlobPtr<int>
35 void check(size_type i, const std::string& msg);
36 };
37
38 // 若试图访问一个不存在的元素,BlobPtr抛出一个异常
39 template <typename T>
40 class BlobPtr {
41 public:
42 BlobPtr() = default;
43 BlobPtr(Blob<T>& a, size_t sz = 0) : wptr(a.data), curr(sz) {}
44
(gdb) l BlobPtr<int>::operator++
file: "Blob.h", line number: 137
file: "Blob.h", line number: 151
(gdb) l BlobPtr<int>::operator++(int)
146 check(curr, "operator-- past begin of BlobPtr");
147 return *this;
148 }
149
150 template <typename T>
151 BlobPtr<T> BlobPtr<T>::operator++(int) {
152 auto ret = *this;
153 ++*this;
154 return ret;
155 }

C++支持重载,因此查看自增运算符时会提示两个地方,如果这两个地方在一起,则会一起显示。可以通过指定函数参数列表来查看特定的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
(gdb) b BlobPtr<int>::operator++
Breakpoint 1 at 0x402099: BlobPtr<int>::operator++. (2 locations)
(gdb) i b
Num Type Disp Enb Address What
1 breakpoint keep y <MULTIPLE>
1.1 y 0x0000000000402099 in BlobPtr<int>::operator++() at Blob.h:137
1.2 y 0x000000000040217e in BlobPtr<int>::operator++(int) at Blob.h:151
(gdb) b BlobPtr<int>::operator++(int)
Note: breakpoint 1 also set at pc 0x40217e.
Breakpoint 2 at 0x40217e: file Blob.h, line 151.
(gdb) i b
Num Type Disp Enb Address What
1 breakpoint keep y <MULTIPLE>
1.1 y 0x0000000000402099 in BlobPtr<int>::operator++() at Blob.h:137
1.2 y 0x000000000040217e in BlobPtr<int>::operator++(int) at Blob.h:151
2 breakpoint keep y 0x000000000040217e in BlobPtr<int>::operator++(int) at Blob.h:151

可以在函数设断点,可以看到,断点编号是1.1和1.2,因为有2处重载,因此也可以通过明确参数列表来为特定的重载设置断点。

总结:通过list查看特定函数或类后,就可以针对特定函数设置断点,也可以针对该函数所在文件的某一行设置断点,这样就不用盲目地step了,而是精确地跳转到指定函数。

C标准I/O库自定义缓冲区

1. 标准I/O库的缓冲机制

Linux上C标准I/O库封装了底层的write()read()等系统调用,以减少系统调用次数。
比如现在需要打印10000次”hello world”,如果直接用系统调用

1
2
for (int i = 0; i < 10000; i++)
write(STDOUT_FILENO, "hello world", 11);

那么系统调用write()会执行10000次。
如果用C标准库的printf()fputs()之类,比如

1
2
for (int i = 0; i < 10000; i++)
fputs("hello world", stdout);

在我的系统(Ubuntu 16.04)上write()只会执行108次,查看write()调用次数可以借助strace工具,比如我是用

1
2
strace ./a.out 2>err
grep "write(" err | wc -l

查看的,如果想实际看看缓冲区大小,可以直接打开err文件,可以看到下列输出

1
2
3
4
write(1, "hello worldhello worldhello worl"..., 1024) = 1024
...(省略若干行)
write(1, "orldhello worldhello worldhello "..., 1024) = 1024
write(1, "rldhello worldhello worldhello w"..., 432) = 432

每次执行C标准库的打印函数时,并未立刻调用write()将字符串打印到屏幕上(严谨点说,write()也只不过是将内容递交给内核缓冲区),而是等到填满缓冲区后再调用write()
通过这样的缓冲机制,可以减少系统调用的次数,普通函数调用不涉及到用户态和内核态的切换,因此开销远低于系统调用。
缓冲区大小没有严格定义,即使在同一系统上,打印到不同文件中的缓冲区大小也不一样。比如将输出重定向到普通文件

1
2
3
# strace ./a.out >out 2>err
# grep "write(" err | wc -l
27

查看err文件可以看到缓冲区大小变成了4096。
再就是最后一次write(),可以发现即使未等到填满缓冲区,仍然打印出来了,原因是程序正常终止(调用exit())时会关闭所有文件流,从而导致缓冲区被刷新,效果等价于调用fflush()
比如在每次fputs()之后加上fflush(stdout);,可以发现write()调用执行了10000次。
或者在for循环之后,加上write(STDOUT_FILEFNO, "exit(0)", 7),可以发现err文件中最后两次write()

1
2
write(1, "exit(0)", 7)                  = 7
write(1, "rldhello worldhello worldhello w"..., 432) = 432

上述缓冲方式为全缓冲,即等缓冲区填满才调用底层I/O函数。而常见的标准输入流和标准输出流都是采用行缓冲,即遇到换行符才调用底层I/O函数。
比如调用fgets()函数时,等键盘敲入回车键时函数才会返回,如果是直接用read()系统调用,则是等待固定字符数量被键盘敲入才返回。
又比如调用printf("hello\n");时,由于末尾有换行符,因此会调用write()将其打印到屏幕上而不是等输出缓冲区填满。
另外,标准错误流是全缓冲的。

2. 使用自定义缓冲区

正是因为标准库没有严格定义缓冲区的设计,因此才催生了用户自定义缓冲区的需求。
通过man setbuf可以看到手册对下列函数的说明

1
2
3
4
void setbuf(FILE *stream, char *buf);
void setbuffer(FILE *stream, char *buf, size_t size);
void setlinebuf(FILE *stream);
int setvbuf(FILE *stream, char *buf, int mode, size_t size);

其中setbuffer()setlinebuf()需要定义_BSD_SOURCE宏。

  • stream: 缓冲区对应的文件流
  • buf: 自定义缓冲区,若为NULL则使用系统自带缓冲区;
  • mode: 缓冲方式,下列三个宏之一
mode宏 意义
_IONBF 无缓冲,此时buf和size失去了意义
_IOLBF 行缓冲
_IOFBF 全缓冲
  • size: 缓冲区大小至少有size字节

本质上前3个库函数都是调用setvbuf()函数,对应关系如下

原始调用 等价调用
setbuf(stream, buf) setvbuf(stream, buf, buf ? _IOFBF : _IONBF, BUFSIZ)
setbuffer(stream, buf, size) setvbuf(stream, buf, buf ? _IOFBF : _IONBF, size)
setlinebuf(stream) setvbuf(stream, NULL, _IOLBF, 0)

可见setbufsetbuffer是用的自定义缓冲区,区别只是前者使用标准库的宏BUFSIZ作为缓冲区大小,后者使用size参数。
setlinebuf则仍然使用标准库的缓冲区,只不过缓冲机制改成行缓冲。

3. 自定义缓冲区的陷阱

一般情况下没必要自定义缓冲区,除非能证明在当前场景下你的自定义缓冲区有性能优势,以及这个性能提升能解决系统的效率瓶颈。毕竟自定义缓冲区会遇到一些问题,这也是这章要讲的。

3.1 缓冲区的生存周期

首先是手册上给出的示例

1
2
3
4
5
6
7
8
9
10
#include <stdio.h>

int
main(void)
{
char buf[BUFSIZ];
setbuf(stdin, buf);
printf("Hello, world!\n");
return 0;
}

stream关闭之前,buf必须存在,否则在关闭时会出问题。在第1章也提过,进程终止时会导致缓冲区被刷新。而main()结束在这些额外操作之前,此时栈上的buf空间会被回收。
正确的方式是将缓冲区定义为全局或静态变量,或者采用malloc()动态申请内存作为缓冲区,并保证在stream被关闭之后才free()释放缓冲区占用内存。
PS: 虽然在我的系统上实际执行这段代码运行正常,设置在非main函数中定义局部缓冲区仍然运行正常,但这实质上是不合法的。

3.2 缓冲区溢出

它指定的是缓冲区的最小大小,然而即使你设定的size比你的buf数组大小要大,该函数调用也不会出错,这样就造成了缓冲区溢出的问题。
给出下列代码

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>
#include <stdlib.h>

static char g_buf[4];
static int g_i = 0;

int main() {
// 模拟缓冲区溢出,全缓冲模式
setvbuf(stdout, g_buf, _IOFBF, sizeof(g_buf) + 4);
for (; g_i < 5; g_i++)
printf("%d\n", g_i + 10000);
return 0;
}

输出结果是

1
2
3
$ ./a.out
10000
10011

来解释下原因,这里我设置的size为8,也就是说,标准库把g_buf开始的8个字节都当做缓冲区,由于g_buf本身只有4个字节,因此会把后面4个字节,也就是g_i所占的内存作为缓冲区。
使用gdb调试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Breakpoint 1, main () at stdout.c:12
12 printf("%d\n", g_i + 10000);
(gdb) p g_buf
$1 = "\000\000\000"
(gdb) x/8bx &g_buf[0]
0x60104c <g_buf>: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
(gdb) c
Continuing.
10000
Breakpoint 1, main () at stdout.c:12
12 printf("%d\n", g_i + 10000);
(gdb) p g_buf
$2 = "\n\000\000"
(gdb) x/8xb &g_buf[0]
0x60104c <g_buf>: 0x0a 0x00 0x00 0x00 0x01 0x00 0x00 0x00
(gdb) n
11 for (; g_i < 5; g_i++)
(gdb) x/8cb &g_buf[0]
0x60104c <g_buf>: 10 '\n' 49 '1' 48 '0' 48 '0' 48 '0' 49 '1' 10 '\n' 0 '\000'
(gdb) x/8xb &g_buf[0]
0x60104c <g_buf>: 0x0a 0x31 0x30 0x30 0x30 0x31 0x0a 0x00
(gdb) p/x g_i
$10 = 0xa3130

执行完第1次循环后,g_i的值为1,由于系统是小端的,所以低位的0x01放在低地址,也就是g_buf[4]的位置上。
执行完第2次循环后,缓冲区后4个字节变成了0x30 0x31 0x0a 0x00,因此g_i的值变成了0x000a3130,g_i < 5不再成立,跳出了循环。

4. 再谈全缓冲方式

本来在写博客的时候到上一节就戛然而止了,但是突然发现一个问题。
注意到3.2中第2次跳出循环时,缓冲区中的内容:”\n10001\n”,第1次write()的只有5个字节”10000”,而缓冲区大小是8。
也就是说,全缓冲并未按照我在第1节中我说明的那样去运作,即等到缓冲区满了才刷新。
strace查看write()的调用情况验证了我的观点

1
2
write(1, "10000", 5)                    = 5
write(1, "\n10011\n", 7) = 7

更改代码重新运行,看看打印10000到10005是怎样?

1
2
for (g_i = 10000; g_i < 10005; g_i++)
printf("%d\n", g_i);
1
2
3
4
5
6
write(1, "10000", 5)                    = 5
write(1, "\n10001\n1", 8) = 8
write(1, "0002", 4) = 4
write(1, "\n10003\n1", 8) = 8
write(1, "0004", 4) = 4
write(1, "\n", 1) = 1

这次可以看到填满缓冲区再打印的情况,但只是中间2次。由于printf是格式化输出,在把int转换成字符串时会计算长度,会不会是这个原因呢?
修改代码如下

1
2
3
4
5
printf("%s", "10001\n");
printf("%s", "10002\n");
printf("%s", "10003\n");
printf("%s", "10004\n");
printf("%s", "10005\n");
1
2
3
4
5
6
write(1, "10001", 5)                    = 5
write(1, "\n10002\n1", 8) = 8
write(1, "0003", 4) = 4
write(1, "\n10004\n1", 8) = 8
write(1, "0005", 4) = 4
write(1, "\n", 1) = 1

和预想的不一样,那么直接改成fputs("10001\n", stdout)呢?

1
2
3
4
5
write(1, "10001\n", 6)                  = 6
write(1, "10002\n10", 8) = 8
write(1, "003\n", 4) = 4
write(1, "10004\n10", 8) = 8
write(1, "005\n", 4) = 4

说明全缓冲模式下printf("%s", s)fputs(s, stdout)并不是完全等价的。
看来只有去看源码了,查看glibc版本

1
2
3
4
# ldd a.out | grep libc
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f49ed348000)
# /lib/x86_64-linux-gnu/libc.so.6
GNU C Library (Ubuntu GLIBC 2.23-0ubuntu9) stable release version 2.23, by Roland McGrath et al.

glibc官网下载2.23版本的源码。
具体地源码阅读就不在本篇讲述,没有习惯glibc的一些宏,读起来还是很吃力的
这里简单讲一点,找到libio/vsnprintf.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
int
_IO_vsnprintf (char *string, _IO_size_t maxlen, const char *format,
_IO_va_list args)
{
// 该结构体包含2个字段
// _IO_strfile f; // 文件相关结构,暂不深入
// /* This is used for the characters which do not fit in the buffer
// provided by the user. */
// 重点在这,定义了额外的缓冲区来保存用户缓冲区可能无法保存的字符
// 比如snprintf(buf, 2, "%d", 10000);
// 用户缓冲区buf光2个字节无法保存"10000"这5个字符,多余的就存在overflow_buf中
// char overflow_buf[64];
_IO_strnfile sf;
int ret;
#ifdef _IO_MTSAFE_IO
sf.f._sbf._f._lock = NULL;
#endif

/* We need to handle the special case where MAXLEN is 0. Use the
overflow buffer right from the start. */
// 通过snprintf(NULL, 0, format, ...)取得格式化后字符串实际大小就是在
// 这里实现的。利用了overflow缓冲区,并重置maxlen为其大小(64)。
// 因此string参数此时没有意义,可以随便设置,并不一定需要为NULL。
if (maxlen == 0)
{
string = sf.overflow_buf;
maxlen = sizeof (sf.overflow_buf);
}

_IO_no_init (&sf.f._sbf._f, _IO_USER_LOCK, -1, NULL, NULL);
_IO_JUMPS (&sf.f._sbf) = &_IO_strn_jumps;
string[0] = '\0';
_IO_str_init_static_internal (&sf.f, string, maxlen - 1, string);
// TODO: _IO_vfprintf实质上是vfprintf,参考stdio-common/vfprintf.c
// 具体实现有400多行,比较麻烦
ret = _IO_vfprintf (&sf.f._sbf._f, format, args);
if (sf.f._sbf._f._IO_buf_base != sf.overflow_buf)
*sf.f._sbf._f._IO_write_ptr = '\0';
return ret;
}

之后有空再详细看下怎么实现的,留下的问题:
C格式化主要解析的就是字符串和整型,overflow_buf只有64个字节,对于整型是足够了,
但是对于较长的字符串是如何保存的呢?
或者说并不是用于保存多出字符的,而是为了计算理论长度的临时缓冲区?
比如对如下的格式化字符串和格式化参数

1
2
3
const char* format = "%s %d";
const char* s = "hello";
int i = 100;

s就直接strlen()计算长度,i则strtol()overflow_buf中再计算长度,最后求和?
再就是问题的关键,全缓冲模式下,假设已经判断出多出字符的数量,如何保存中断位置呢?
我也有个大概思路,如果是在字符串的位置中断,则尽可能用s填充缓冲区剩余部分,然后移动s指针。
如果是在整数中断,则overflow_buf记录整数转换成的字符串,然后用"%s"替换format的"%d"
总之还没有非常明确的思路,以后有空自己写个Buffer类。

hexo多终端同步

简介

hexo的安装还是很简单的,操作也很傻瓜式。常用的基本就下面几个命令

  1. hexo s
    s即server,默认在localhost:4000启动服务器,在浏览器中即可看到效果,可以通过-p选项指定端口。
  2. hexo d
    d即deploy(部署),上传到服务器。一般会加上-g选项在本地生成静态文件。如果不上传服务器,可以直接hexo g生成静态文件。
  3. hexo clean
    删除本地md文件后如果不clean后重新生成,首页可能不会更新。
  4. hexo new
    新建博客,后接博客名,比如hexo new "test",此时hexo框架就会自动生成md文件。自动新建的md文件会生成一些模板信息,因此最好使用命令新建博客。生成之后,用vim等本地编辑器修改即可。
    另外在_config.yml设置post_asset_folder为true,则hexo new会新建md文件的同名目录用来存放图片,并且作为图片的默认路径,也就是说如果待插入图片放在该目录下,路径直接写文件名即可。

至于其他命令可以参考hexo中文文档_config.yml的配置也有较为详细地注释。

新建分支保存源码

hexo是将Markdown文本和图片生成静态网页上传至github的。
master分支目录

上图所示目录即hexo d -ghexo g在本地生成的静态文件,为public子目录。hexo d即上传该目录到仓库master分支。
而博客目录是保存在source/_posts目录下的,因此需要在git仓库中新建一个分支保存博客源文件。创建过程如下

1
2
3
4
5
6
7
git init
git add source themes _config.yml package.json
git commit -m "blog source"
git branch hexo
git checkout hexo
git remote add origin git@github.com:BewareMyPower/BewareMyPower.github.io.git
git push origin hexo

普通的git命令,初始化、添加、提交、创建分支、切换分支、添加远程仓库、推送分支到该仓库。完成后远程仓库hexo分支如下图所示。
hexo分支目录

新环境下部署博客并添加新博客

首先克隆源码分支。

1
git clone -b hexo git@github.com:BewareMyPower/BewareMyPower.github.io.git

由于hexo分支仅仅是源码,缺少了将源码转换成网页的nodejs模块(即hexo),所以需要安装hexo在该目录。

1
2
3
cd BewareMyPower.github.io/
npm install hexo
npm install

然后会发现目录下多了个node_modules目录,即保存nodejs相关模块。
注意后面一个npm install不可少!否则会缺少某些模块,比如缺少hexo-asset-image模块导致图片无法显示。
此外无论是生成静态文件还是启动服务器,源码目录不受影响,因此可以放心大胆地操作。

1
hexo new "hexo多终端同步.md"

边修改边hexo s查看效果,直到修改完毕,便可以上传了,当然,也是分为上传源码到hexo分支和上传编译后静态文件到master分支这两步。

1
2
3
4
git add source/_post/多终端同步.md source/_posts/hexo多终端同步
git commit "new blog"
git push origin hexo
hexo d

其他问题

之前在阿里云上hexo init出错

1
2
3
4
> nunjucks@3.1.3 postinstall /root/hexo/node_modules/nunjucks
> node postinstall-build.js src

sh: 1: node: Permission denied

原因是我是root用户,而npm的默认用户ID是500,因此要设为0。可以直接修改.npmrc文件,也可以npm config命令修改:

1
2
3
4
5
~# npm config set user 0
~# npm config set unsafe-perm true
~# cat .npmrc
user=0
unsafe-perm=true

hexo博客置顶需要修改npm模块代码,修改node_modules/hexo-generator-index/lib/generator.js如下,按照top值排序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
'use strict';

var pagination = require('hexo-pagination');

module.exports = function(locals) {
var config = this.config;
var posts = locals.posts.sort(config.index_generator.order_by);

posts.data = posts.data.sort(function(a, b) {
if(a.top && b.top) {
if(a.top == b.top) return b.date - a.date;
else return b.top - a.top;
}
else if(a.top && !b.top) {
return -1;
}
else if(!a.top && b.top) {
return 1;
}
else return b.date - a.date;
});

var paginationDir = config.pagination_dir || 'page';

return pagination('', posts, {
perPage: config.index_generator.per_page,
layout: ['index', 'archive'],
format: paginationDir + '/%d/',
data: {
__index: true
}
});
};

比如我这篇博客的top设置为0。

1
2
3
4
5
6
---
title: hexo多终端同步
date: 2018-10-15 20:07:19
tags: 搭环境
top: 0
---