Linux服务端多线程编程

读书笔记

Posted by zoushengfu on September 4, 2021

第一章:线程安全的对象生命周期管理

构造函数与多线程

构造函数做到多线程安全,唯一要求是在构造期间不要泄露this指针,即:

  • 在构造期间不要注册任何回调函数
  • 不要在构造期间把this指针传给跨线程对象
  • 即使在构造函数最后一行也不可以,因为此类可能是基类

通常使用方式是二段式构造,即构造函数+initilalize()函数

析构函数遇到多线程存在问题

  1. 其他线程函数是不是在访问即将析构对象?
  2. 如果保证在执行成员函数时期对象会不会被另一个线程析构?
  3. 在执行成员函数时,其析构函数会不会执行到一半

作为数据成员的mutex不能保护析构函数,因为作为成员变量的mutex其生命周期与对象的生命周期相同,析构操作可能发生在对象身亡之时和身亡之后;其次,对于基类,当调用起析构函数时,子类已经析构了,则mutex不能保证整个析构过程

解决上述的方案是使用shared_ptr

神奇shared_ptr

出现内存错误的几个方面:

  • 缓冲区溢出:使用std::vector/std::string或者自定义的Buffer class来管理缓冲区
  • 野指针:使用shared_ptr
  • 重复释放:使用scoped_ptr
  • 内存泄漏:使用scoped_ptr
  • 不配对的new[]/delete: 把new[]替换为std::vector或者scoped_array

再论shared_ptr的线程安全

shared_ptr不是100%线程安全的,但是他的引用计数是安全无锁的,但是对象的读写则不是。shared_ptr的线程安全和内建类型是一样的,即:

  • 一个shared_ptr对象实体可以被多个线程同时读取
  • 两个shared_ptr对象可以被2个线程同时写入(析构算是写入)
  • 如果多个线程同时读取一个shared_ptr对象,则需要加锁(在多核系统上可以考虑使用spin lock)

    以上是shared_ptr对象本身线程安全,不是它管理的对象的线程安全级别

shared_ptr作为函数参数传递时不复制,用reference to const作为参数类型即可

shared_ptr技术与陷阱

意外延长对象的声明周期

shared_ptr允许拷贝构造函数和赋值函数,如果一不小心遗留了一个拷贝,那么对象的生命周期可能就会延长。比如使用std::bind时如果实参为shared_ptr,则实参会被拷贝一份。

函数参数

作为函数参数时,由于需要引用计数,因此拷贝的成本比原始指针要高,因此一般使用referenc to const作为参数类型

析构函数在创建的时候被捕获

  • shard_ptr可以持有任何的对象,而且能安全的释放
  • shared_ptr对象可以跨模块边界
  • 二进制兼容,即使对象的大小变了,旧的client仍然可以使用新的动态库
  • 析构函数可以定制

析构所在的线程

最后一个指向对象的shared_ptr离开其作用域时,该对象会在同一个线程析构掉,未必是对象创建的线程

现成的RAII handle

shared_ptr作为现成的handle对象,需要避免循环引用,通常做法是owner持有指向child的shared_ptr, child持有指向owner的weak_ptr

对象池

当时使用std::bind并且传入this指针时,需要注意该对象是否有可能析构。为了解决这个问题引入enable_shared_from_this, 该类继承enable_shared_from_this并且使用shared_from_this()代替this指针

第二章:线程同步精要

互斥锁

只使用非递归的mutex

死锁

  • 单线程死锁:抽象出无锁调用的公共方法
  • 多线程死锁

条件变量

对于wait端:

  1. 必须与mutex一起使用,布尔表达式的读写需要mutex保护
  2. 在mutex上锁的时候才能调用wait
  3. 把判断布尔条件和wait()放到while循环中

对于signal/broadcast端:

  1. 不一定要在mutex已上锁的情况下调用signal
  2. 在single之前需要修改布尔表达式
  3. 修改布尔表达式通常需要mutex保护
  4. broadcast表示状态变化,signal表示自愿可用

不要使用读写锁和信号量

见到一个数据读多写少,使用读写锁未必正确:

  1. 在持有read lock的情况下,有可能在维护阶段修改了其中的状态
  2. 从性能角度来看,在read lock加锁阶段的开销不会比mutex lock小,因为其需要维护reader数据
  3. read lock有可能提升为write lock,也有可能不提升
  4. read lock可重入,但是write lock不可重入,为了防止write lock饥饿,write lock会阻塞read lock,导致read lock在重入的时候死锁(旧的read lock没有释放锁,又有write lock存在,导致新的read lock不能加锁)

信号量存在的问题:

  • semaphore has no notion of ownership
  • 其有自己的计数值,与代码的数据结构冲突,可能出错

线程安全的Singleton实现

template<typename T>
class Singleton : boost::noncopyable
{

public:
    static T& instance()
    {

        pthread_once(&ponce_, &Singleton::init);//只被初始化一次
        return *value_;
    }

private:
    Singleton();
    ~Singleton();
    static void init()
    {

        value_ = new T();
    }

    static pthread_once_t ponce_;
    static T* value_;
};

template<typename T>
pthread_once_t Singleton::ponce_ = PTHREAD_ONCE_INIT;

template<typename T>
T* Singleton<T>::value_ = NULL;

c++11内存模型

sleep不是同步原语

生产代码中等待可分为2种:

  1. 等待资源可用,等待select、epoll_wait或者等待条件变量上
  2. 等待进入临界区(等待mutex上),通常时间比较短

借shared_ptr实现copy-on-write

  • 对于write端,进入临界区内之后,如果shared_ptr的引用计数为1,则直接修改;如果引用不为1,则reset shared_ptr并new新的内存空间复制给shared_ptr,最后退出临界区
  • 对于read端,进入临界区内之后,拷贝shared_ptr,退出临界区。在临界区外进行读操作

第三章:多线程服务器的使用场合与常用编程模型

进程与线程

可以把进程比喻成一个”人”,每个人都有自己的记忆(memory),人与人通过谈话(消息传递)来交流,谈话既可以面谈(同一台服务器),也可以在电话里谈(不同服务器,有网络通信)。面谈和电话谈的区别在于面谈可以立即知道对方是否死了,而电话谈只能通过周期性的心跳来判断对方是不是活着。 然后可以思考:

   
容错 万一有人死了
扩容 中途有新人嫁过来
负载均衡 把甲的活儿挪给已做
退休 甲要修复bug,先别派新任务,等他做完手上的事情就把他重启

线程的特点是共享地址空间,从而可以高效地共享数据。一台机器上多的进程可以高效的共享代码段,但不能共享数据。

单线程服务器常用的编程模型

基于事件驱动的编程模型,要求时间回调函数必须是非阻塞的;对于涉及到网络io请求的响应式协议,容易割裂业务逻辑,使其散布于多个回调函数中。

多线程服务器的程勇编程模型

one loop per thread

在此种模型下,程序每个IO线程有一个event loop,用于处理读写和定时事件。

好处:

  1. 线程数目基本固定,可以在程序启动时设置,不会频繁创建与销毁
  2. 可以方便在线程间调配负载(数据量大的connection可以独占一个线程,实时性要求高的connection可以独占一个线程)
  3. IO事件发生的线程是固定的,同一个TCP连接不必考虑事件并发

推荐模式

one(event) loop per thread + thread pool

  • event loop用作IO多路复用,配合非阻塞io和定时器
  • thread pool用来做计算,可以是任务队列或者生产消费队列

进程间通信只用TCP

pipe的优缺点

  1. pipe在收发字节流方面,是单向
  2. 进程双向通信还得开2个文件描述符,并且需要有父子进程才能使用
  3. pipe经典的应用场景是用来异步唤醒select调用

TCP的优缺点

  1. TCP通信可以跨机器,提高了scalability
  2. port是独占的,可以防止程序重复启动
  3. 可以通过tcpdump和wireshark来重现和记录
  4. 可以跨语言通信
  5. TCP是字节流协议,只能顺序读取,有写缓冲;共享内存需要a进程填好一块内存让b进程来读,基本是“停等”方式(需要考虑如果数据写到一半怎么办)

多线程服务器的适用场景

必须用单线程的场合

  • 程序可能fork
  • 限制程序的CPU的占有率

单线程程序的优缺点

  • 简单
  • 非抢占的容易发生优先级反转

适合多线程程序的场景

提高响应速度,让IO和”计算”相互重叠,降低latency。

  • 有多个CPU可用
  • 线程间有共享数据,如果没有共享数据,使用master+worker模型就行
  • 共享数据可以修改,不是静态的常量,否则通过进程间共有shared memory就行
  • 提供非均质的服务,防止优先级反转
  • 有相当的计算量
  • 利用异步操作
  • 能scale up
  • 具有可预测性
  • 多线程能有效划分责任与功能

“多线程服务器的适用场景”例释与解答

  1. 一个多线程的进程和多个相同的单线程进程怎么取舍? 如果一次请求所访问的内存比较大,那么就使用多线程,避免CPU cache换入换出,影响性能
  2. 还有那些多线程的编程模型 Proactor模型可以提高吞吐,但是不能降低延迟
  3. 多线程程序如何让IO和”计算”相互重叠,降低latency? 把IO操作通过BlockingQueue交给别的线程去做,自己不必等待
  4. 多线程能降低响应时间吗? 如果设计合理,充分利用多核资源的话,可以

第五章高效的多线程日志

使用stream风格日志好处是当输出的日志级别高于语句的日志级别时,打印日志时个空操作。

功能需求

日志不能为每条消息都flush磁盘,解决方案通常有2个:

  • 定义将缓冲区的日志flush到磁盘
  • 每条内存中的日志消息都带有哨兵值,其值为某个函数的地址,这样通过coredump文件中查找哨兵值就能查出来尚未写入的消息

性能需求

  • 时间字符串中的日期和时间两部分是缓存的
  • 日志消息的前4个字段是定长的,可以避免运行期间求字符串
  • 线程id预先格式化为字符串
  • 每行日志消息的原文件部分在编译期计算来获得basename

多线程异步日志

第七章muduo编程示例

muduo buffer类的设计与使用

muduo的io模型

非阻塞几乎和io多路复用几乎一起使用:

  • 没有人会用通过轮询的方式来检查非阻塞io是否完成,这样太浪费CPU
  • 阻塞io中的read、write、accept和connect会阻塞当前线程

为什么非阻塞网络编程中应用层buffer是必须的

非阻塞io的核心思想是避免阻塞在read或者write上,io线程只能阻塞在io复用多路函数上。 使用水平触发而没有使用边缘触发的原因:

  • 文件描述符比较少时,poll未必比epoll低效
  • 水平除非更容易编程,以往select和poll经验都可以使用,方便切换
  • 使用水平触发时读写操作不必等到出现EAGAIN,节省系统调用(边缘触发需要一次性的把缓冲区的数据读完为止,也就是一直读,直到读到EGAIN(EGAIN说明缓冲区已经空了)为止,因为这一点,边缘触发需要设置文件句柄为非阻塞)

Buffer功能需求

使用readv代替read系统调用,利用栈上临时空间避免分配过大内存导致内存浪费,同时减少了系统调用。

read会从fd当前的offset处开始读,读取完n byte后,该fd的offset会增加n byte(假设可以读取到nbyte),下一次read则从新的offset处开始读。而pread则是从指定的offset处开始读,这个offset是相对于0的一个绝对值,与fd当前的offset没有关系。read和pread的区别就是,read会改变fd的offset,而pread不会改变。 pread的实际操作类似于lseek+read,即先将offset调整到指定值,再调用read读取数据,两者的区别在于 pread是一个原子操作,从而可以保证一定是从指定的offset处开始读;另外就是pread读取完数据会恢复执行之前的offset,即pread操作前后的offset是一致的。readv则是从fd中读取数据到多个buf,buf数为iovcnt,每个buf有自己的长度(可以一样),一个buf写满(写指读出数据并保存),才接着写下一个buf,依次类推。 preadv与readv的关系,与上述read和pread的关系一样。除了文件 I/O,还有网络 I/O,read/write 也可用于网络 I/O

Buffer的数据结构

Buffer的操作

内部是一个std::vector 是一个连续内存,还有2个index类型为int。

readIndex和wirteIndex为下标而不是指针,因为vector在自动增长扩容时指针会失效 vector::resize会调用memset,稍微有点浪费

其他设计方案

不使用std::vector

zero copy

如果对性能有极高的要求,受不了copy()与resize(),可以考虑实现分断连续的zero copy buffer,在配合gather cattor IO,基本思路是不要求数据在内存中连续。

第九章: 分布式系统工程实践

分布式系统中的心跳协议的设计

使用TCP连接作为分布式系统中进程通信方式,其好处是在进程意外退出时,操作系统会关闭进程使用的TCP socket,往对方发送FIN报文,但是还是需要应用层心跳,因为:

  • 如果是因为机器崩溃或者硬件故障导致的重启,没有机会发送FIN报文
  • FIN报文可能出现丢包
  • 网络故障

同时使用tcp keepalive也不行,keepalive是由操作系统来发起探查的,及时进程死锁或者阻塞,操作系统也会正常首发消息。

  • 一般发送周期和检查周期相同为T,timeout>T,虽然发送周期和检查周期都为T,但是无法保证每个检查周期恰好收到一条心跳,有可能一条都没有收到,通常可取timeout为2T
  • 心跳消息也应包含每个进程的唯一标识以及负载状况,便于客户端做负载均衡
  • 如果发送心跳的过程中有其他中断的消息进程,应该还需要在消息中加上发送时间,防止消息在中转过程中因堆积而出现的假心跳
  • 要在工作线程发,不要单独起一个“心跳线程”
  • 与业务消息用相同的连接,不要单独用“心跳连接”

分布式系统中的进程标识

错误做法

  • ip:port
  • host:pid

正确做法

ip:port:start_time:pid

第十章:C++编译链接模型精要

c++至今没有模块机制,不能使用import或者using引入依赖的库,必须使用include头文件来机械地将库的接口声明以文本替换的方式载入,重新parse一遍。存在以下2个问题:

  • 头文件具有传递性,引入不必要的依赖
  • 头文件是在编译时使用,库文件在运行时使用,二者的时间差可能导致二进制不兼容

C语言的编译模型及其成因

为什么C语言需要预处理

为了减少内存使用的情况实现分离编译,C语言采用“隐式函数声明”的做法,代码在使用前文未定义的函数时,编译器不需要检查函数原型,会出现连接错误而非编译错误。为了减少各个源文件的重复代码,同时提高代码的可移植性,将公共信息坐成头文件,程序include用到的头文件即可。

C语言的编译模型

由于不能将整个语法树保存在内存中,C语言按照单遍编译的方式来设计。编译器只能看到目前已经解析过的代码,但不到之后的代码。所以:

  • 结构体必须先定义,才能访问其成员,否则不知道成员的类型和偏移量,不能生成目标代码
  • 局部变量有需要先定义再使用,否则不知道类型已经在stack中的位置,不能生成目标代码
  • 对于外部变量,编译器只需要知道类型和名字,不要其地址。在生成目标代码的地址是个空白,留给链接器去填
  • 编译器看到一个函数调用时按照隐式函数声明原则,可以生成调用函数的汇编代码,但是函数的实际地址不确定,留给链接器去填

C++的编译模型

单遍编译

C++也继承了单遍编译,编译器只能根据目前看到的代码作出决策,即使读到后面的代码也不会影响前面作出的决定。

void foo(int) {
    printf("foo(int);\n");
}

void bar() {
    foo('a'); //调用 foo(int)
}

void foo(char) {
    printf("foo(char)"); //修改foo(char)和foo(int)位置结果不一样
}
int main() {
    bar();
}

向前声明

为了完成语法检查生成目标代码,编译器需要知道函数的参数个数和类型以及返回值类型,并不需要知道函数体的实现(除非是inline函数)。需要在某个源文件中定义这个函数,否则会链接错误。如果在定义的时候把函数参数类型写错了,则在生成目标的文件的时候不会报错,但是在链接的时候会报错。

以下2中情况可以向前声明:

  • 定义或者声明类的指针或者引用,包括用于函数参数、返回类型、局部变量、类型成员变量
  • 声明一个类为参数或者返回类型的函数,但是如果要调用这个函数就需要知道类的定义,因为需要使用拷贝构造函数和析构函数

C++链接

c++相比于c语言的连接,主要增加了两项:

  • 函数重载,需要类型安全的连接(name mangling)
  • vague linkage,同一个符号有多份互不冲突的定义

函数重载

返回类型不参加函数重载

inline函数

如果编译器无法展开inline函数,则每个编译单元都会生成一个inline函数的目标代码。然后链接器会从多份实现中任选一个保留,其余的会丢弃。同时编译器自动生成的class的析构函数也是inline函数。

模板

  • 模板编译连接的不同之处在于,以上具有external linkage的对象通常会在多个单元被定义,链接器必须进行重复代码的消除,才可以生成正确的可执行文件。但是在-O2编译下,编译器会把短函数都inline展开。
  • 对于private成员函数模板,只需要在头文件中给出声明,因为用户代码不会调用它,无法实现随意具现化,不会造成连接错误

虚函数

一个多态class的vtable应该恰好被一个目标文件定义,但是C++无法判断是不是应该在当前编译单元中生成vtable定义,所以为每个编译单元都生成vtable,交给连接器去消除重复数据。如果我们不希望vtable导致目标文件膨胀,在头文件的class定义中声明out-line 虚函数

第十一章:反思C++面向对象与虚函数

朴实的C++设计

程序库的二进制兼容

什么是二进制兼容性

在升级库文件的时候,不需要重新编译使用了这个库的可执行文件或者其他库文件,并且程序的功能不被破坏。

有哪些情况会破坏库的ABI

C++编译器ABI主要包含以下几个方面:

  • 函数参数传递方式,比如:x86-64用寄存器来传函数的前4个参数
  • 虚函数的调用方式,通常是vptr/vtbl机制,然后用vtabl[offset]来调用
  • struct 和class的内存布局,通过偏移量来访问数据成员
  • name mangling
  • RTTI和异常处理的实现

编译器如何判断一个改动是不是二进制兼容的,主要看旧的头文件是不是和新版本的动态库的使用方法兼容,因为新的库必然新的头文件,现在的二进制可执行文件还是按照旧的头文件中的方式来调用。

二进制不兼容的例子:

  • 定义的non-virtual函数void foo(int),参数类型改成了double,则会出现undefined symbol错误,因为mangled name不同。但是对于virtual函数,修改其参数类型,则不会出现错误,因为虚函数的决议通过便宜量,并不是靠符号名。
  • 给函数添加默认参数,现有的可执行文件无法传这个额外的参数
  • 增加虚函数,会造成vtbl里面的排列变化,不考虑只在莫问增加这种技巧
  • 增加默认模板类型参数,这回导致name mangling发生变化
  • 增加enum的值,这会早上错误,在末尾添加除外
  • 给class增加数据成员,通常是不安全的。但是如果代码里面不是通过new 构造对象,而是通过factory返回对象指针或者shared_ptr,可能是安全;或者不需要用到data member的offset,也可能是安全的

哪些做法多半是安全的

  • 增加新的class
  • 增加non-virtual成员函数或者static成员函数
  • 修改数据成员的名称,因为二进制代码是按照偏移量来访问的

反面教材:COM

解决方法

  • 采用静态链接,完全从源码编译出可执行文件
  • 通过动态库的版本来管理控制兼容性
  • 用pimpl技法,在头文件中只暴露non-virtual接口,并且class的大小是固定的

避免使用虚函数作为库的接口

C++程序库的作者的生存环境

对于C++库需要考虑的问题:

  • 以什么方式发布?动态库还是静态库
  • 以什么方式暴露接口?全局函数?还是non-virtual成员函数或者以virtual函数为接口?

虚函数作为库的接口的两大用途

  • 调用
  • 回调

虚函数作为接口的弊端

如果要增加新接口,同时保证二进制兼容性,实现起来比较丑陋。

假如Linux系统调用以COM方式实现

实现结果

动态库接口的推荐做法

  • 对于使用范围比较少的情况,在头文件和class中有意识的区分用户可见和用户不可见两部分。对于用户可见部分升级时注意二进制兼容性,对于不可见部分不必在意。
  • 对于使用范围比较广的情况,使用pimpl方式。考虑多使用non-member non-friend 做为接口,不要使用虚函数;并且需要显示声明构造函数和析构函数,并且不能是inline的;在库实现中把调用转发给实现类,这部分代码都放到so中;如果需要新加函数,不需要通过继承来扩展,可以原地修改。libevent2中struct event_base是个opaque pointer,客户端看不到其成员。

以boost::function和boost::bind取代虚函数

随着系统的演进,原来正确的决定可能变成错误的。如果一个类有很多的子类,如果想把这个class在继承树上从一个节点挪到另一个节点,可能要触及到用这个class的客户代码,以及从这个class派生出来的全部代码,牵一发而动全身。因此不采用基于继承的设计。

在实现层面,事件回调通常是通过虚函数执行,往往需要实现新派生类,这些派生类的生命周期比较模糊,难以确定。

基本用途

对程序库的影响

线程库

class Thread {

  public:
    typedef boost::function<void()> ThreadCalback;
    Thread(ThreadCalback cb): cb_(cb){}
    void start() {}
  private:
    void run() {
      cb_();
    }
  ThreadCalback cb_;
};

什么时候使用继承?一般是继承boost::noncopyable或者boost::enable_shared_from_this,使用多态代替switch-case以达到简化代码的目的,这个时候数据固定,功能也完全确定(为了接口和实现分离,只会在派生类数据和功能完全固定的情况下使用)

C++经验谈

用异或来交换变量是错误的

  1. 同一份代码在不同的编译平台上编译之后的汇编代码可能不太一样
  2. 代码行数少不一定运行的快

不要重载全局::operator::new()

内存管理的基本要求

new/delete要配对

重载::operator::new的理由

  • 检测代码中的内存错误
  • 优化性能
  • 获得内存使用的统计计数

::operator::new的2种重载方式

  1. 直接替换系统原有的版本
    #include <new>
    void* operator new(std::size_t size);
    void operator delete(void *ptr);
    
  2. 增加新的参数,调用时也提供额外的参数
    void* operator new(std::size_t size, const char* file, int line);
    void operator delete(void* p,const char* file, int line);
    

    可以通过如下方式使用

    Foo *p = new(__FILE,__LINE__) Foo;// 跟踪哪个文件哪一行代码分配了内存,一般统计内存使用状况或者检测内存错误会用这种方式
    

重载operator::new的困境

  1. 不要在library里面重载::operator::new()函数,可能会影响使用library的人
  2. 在主程序里面重载用处不大

解决方法:替换malloc

通过LD_PRELOAD加载so,里面有malloc和free的代替实现。

  • 对于”检测内存错误”:可以用valgrind等工具实现
  • 对于“统计内存使用数据”:可以用backtrace来获得函数调用栈
  • 对于”内存优化”:使用tcmalloc护着TBB等内存优化器

带有符号整数的除法与余数

c99和c++11都规定商向0取整,c89和c++98规定由编译器实现

在单元测试中mock系统调用

在系统函数的依赖注入

  1. 借助面向对象的手段,自己实现Systemc Interface,把系统调用设计成虚函数,在运行单元测试的时候都替换成mock的对象
  2. 在编译和链接期间延迟绑定,声明一个中间层的namespace,把需要mock的函数做成一个library

链接期垫片

如果在程序一开始的时候没有考虑到单元测试需要mock系统调用,可以自己实现一个函数替换系统调用。在链接的时候会优先使用自己定义的函数(仅对动态链接成立;对于静态链接会报multiple definition)

typedef int(*connect_func_t) (int sockfd, const struct sockaddr *addr, socklen_t addrlen);
connect_func_t connect_func = dlsym(RTDL_NEXT, "connect");
bool mock_connect;
int mock_connect_error;

extern "C" int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen) {
  if (mock_connect) {
    errno = mock_connect_error;
    return errno == 0 ? 0: -1;
  } else {
    return connect_func(sockfd, addr, addrlen);
  }
}

其他做法

使用ld –warp 替换动态库中的函数

慎用匿名namespace

C语言static关键字的用法

  1. 用于函数内部修饰变量,表示这种变量的生存周期长于该函数。使用静态变量的函数一般不可重入,也不是线程安全的
  2. 用在文件级别(函数体之外),修饰变量或函数,表示该变量或者函数只有本文件可见,其他文件看不到,也范访问不到改变量或函数

C++语言的static关键字的四种用法

  1. 用于修饰class的数据成员,这种数据成员的声明周期大于class的对象。静态成员是每个class一份(class variable),普通成员是每个实例有一份(instance variable)
  2. 用于修饰class的成员函数,只能访问static变量和静态程序函数,不能访问instance variable 和methord

匿名namespace的不利之处

  1. 明确引用哪个匿名函数比较麻烦
  2. 同一个编译器,编译同一个文件,每次编译后生成的符号表可能不一样

采用有利于版本管理的代码格式

对diff友好的代码格式

  • 多行注释也用//,不用 /**/
  • 一行只定义一个变量
  • 如果函数参数大于3个,在逗号后面换行,每个参数占一样
  • 在调用函数时候,如果参数大于3个,把实参分行写
  • class初始化列表也遵循一行一个
  • namespace不设置缩进

对grep友好的代码风格

  • 操作符重载
  • static_cast与C-style static_cast

再探std::string

string的实现方式:

  • 无特殊处理采用类似std::vector的数据结构
  • 写时复制(COW)
  • 短字符串优化(SSO),利用string对象本身空间来存储短字符串

以上无论哪种方式都要保存三个数据:1)字符串本身 2)字符串的长度 3)字符串的容量

直接拷贝

class string {
public:
    const_pointer data() const { return start; }
    iterator begin() { return start; }
    iterator end() { return end; }
    size_type size() const { return end - start; }
    size_type capacity() const() { return end_of_storage - start; }
private:
    char* start;
    char* end;
    char* end_of_storage;
};

内存布局如图

写时复制

class string {
    strcut Rep {
        size_t size;
        size_t capacity;
        size_t refcount;
        char* data[1];
    };
    char* start;
};

拷贝字符串的复杂度是O(1),但是第一次operator[]的复杂度是O(n) 内存布局如图

短字符串优化

class string {
        char *start;
        size_t size;
        static const int kLocalSize = 15;
        union {
            char buf[kLocalSize +1];
            size_t capacity;
            } data;
};

内存布局如图