std::thread I – 加载(launch)新线程

标准库使用std::thread()启动一个线程, 原型如下:

最直接的方式就是顶一个函数, 将函数及其实参一同传入构造的thread实例, 此外, 在实际编码中, 还有几种常见的编码形式来启动一个线程

使用类

可以借助C++的类的封装, 将一个线程的资源和运行上下文封装在一起:

使用std::thread构造线程对象时, 如果使用临时对象而不是有名对象来构造, 就要注意下面这种写法是错误的.

事实上, 在编译器眼中, std::thread t(BackGroundTask()); 并不是用构造一个临时的BackGroundTask对象并用它来构造一个 std::thread对象, 而是声明了一个 “以函数对象为参数, 返回值为std::thread的函数t, 它的入参函数的参数是空, 返回值为BackGroundTask类型”. 如果修改这句话使之能够按照我们期望的方向构造std::thread实例, 可以改成下面两种写法:

使用lambda

从C++ XI开始, 有了引入的lambda表达式, 我们也可以不用上述的封装方式, 使用lambda的capture list也可以方便的实现有参线程的启动, 重要的是不用单独定义线程执行函数, 特别适合线程函数只启动一次的场景

条件变量 II – std::condition_variable

条件变量是多线程同步的一种常用方法, 两个线程同步, 如果一个线程在到达同步点之前可能等待很久, 以至产生的额外开销超过了线程调度的开销, 这种情况就可以使用条件变量来同步, 反之, 如果等待的开销比较小, 就可以使用while(){sleep_1ms()}的方式来完成同步, 类似的思路和内核的spin_lock和mutex的区别一样.

C++ STL 提供了相对完善的并行编程接口, 当然少不了对于条件变量的实现. 和pthread实现相比, 除了接口更加面向对象, 并无明显不同, 毕竟, “条件变量的正确使用方式只有一种”. 使用条件变量, 需要准备三件材料: 1个条件 + 2个变量 + 多个线程.

  • 1个条件: 和所有的并行代码一样, 搞清楚执行流之间何时同步, 如何同步是整个程序的重中之重
  • 2个变量: 1个条件变量 + 1把mutex, 当然, 在不同的语言中这种mutex的实现可以不同, 但其本质并没有改变
  • 多个线程: 条件变量只能用于多线程同步, 不能用于多进程
Continue reading

条件变量 I – pthread

条件变量是线程同步的另一种方式,实际上,条件变量是信号量的底层实现,这也就意味着,使用条件变量可以拥有更大的自由度,同时也就需要更加小心的进行同步操作。条件变量使用的条件本身是需要使用互斥量进行保护的,线程在改变条件状态之前必须首先锁住互斥量,其他线程在获得互斥量之前不会察觉到这种改变,因为互斥量必须在锁定之后才能计算条件。

模型

pthread_cond_init()

Continue reading

C++ MiniTrick

时间戳打印

master-slave工作线程各自初始化

在master-worker模型中, 二者的初始化逻辑往往不同, 如果第一个执行的线程是master, 可以使用下面的代码来标识出:

Continue reading

C++ 右值引用与move()

C++11之前的标准有两种构造函数: 构造与拷贝构造, 前者用于直接分配一个对象并初始化: Instance original, 后者用于分配一个对象并通过”拷贝已有对象”方式初始化: Instance other = original, 编译器缺省会为每一个类提供一个构造和拷贝构造函数, 但对于管理堆内存等资源的类来说, 这种缺省提供的函数最大的问题就在于很容易造成资源的”浅拷贝”, 而要完成正确的”深拷贝”, 通常需要自己实现一个拷贝构造.

浅拷贝的问题显而易见, 两个对象底层管理着同样一块内存, 一旦一个对象执行了析构函数释放了内存, 那么会导致另外一个对象不可用, 通过重载完成底层内存的拷贝构造可以很好的解决这个问题. 但是, 深拷贝同样也有问题, 如果类中资源巨大, 那么每次拷贝一遍的开销就变得不可接受, 所以 有时候我们还想要这样一种功能: 将资源的管理权交接给另一个对象, 既防止了复制内存的开销, 又防止了两个对象引用了同一块内存. 就功能上来说, 我们完全可以自己封装函数实现这样的功能, 但作为一类和构造相关的功能, 显然如果编译器原生支持更好.

这种管理权交割看起来和拷贝构造如出一辙, 只要提供另外一种构造函数即可. 回顾拷贝构造的场景:

为了控制L3和L4的行为, 我们分别重载了拷贝构造和赋值运算符:

在上述代码中, original_0original_1都是左值, 即既能在等号右边, 也能在等号左边的值, 而我们在拷贝构造的实现中, 用const Instance &来表示这类值.

此外, C++的设计还有一类右值, 即只能出现在等号右边的值, 典型的就是常量

上述代码中的3就是一个右值, 我们只能取其值, 不能赋其值.

Continue reading

C++的引用与const引用

C++ 的引用源自C语言中对指针的使用, 众所周知, C的强大和巨坑都来自于指针, 所以C++着力进行了取其精华, 去其槽粕的改良, 其中, 引用(reference)就是其中一个关键. C++ 的引用可以看做是C中的”指针常量”的封装, “指针常量”, 即不能修改其指向的指针, 该指针的指向必须在初始化时指定, 在此基础上, C++ 引用针对代码的安全性和可读性进行了分类整理升级. C++ 的引用可以分为“引用”“常量引用”,

引用

引用按照 type& ref_name = object的形式初始化, 所以, 初始化引用之前一定要明确即将绑定(bind)的对象的类型. 比如, 变量的地址就是”指针常量”类型, 因为一个变量的地址在编译时期被分配的时候即被确定, 所以不是 type *而是type * const

const 引用

如果说引用是”指针常量”, 那么”常量引用”就是”常量指针常量”: 不但指针的指向不可以改变, 也不可以通过指针改变指向的内容.

有了指针的基础来理解引用, 很多问题就方便解释的多, 比如, 不能用将一个non-const 引用bind到一个const变量/引用上, 因为const指针是比指针更严格的限定, 在全靠程序员自觉的C语言中, 缺省编译器配置下这是一个warning:

但在作为强类型语言的C++中, 这就是一个error:

Continue reading

tfrecord二进制解析

tfrecord是tensorflow基于protobuf框架开发的一种用于持久化训练数据的文件。

protobuf

考虑下面一个简单的protobuf message定义:

这里,我们将val的field_number属性置为1,下文将在序列化文件中将其提取出来。通过 protoc --proto_path=. --python_out=. demo.proto编译生成demo_pb2.py,然后就可以使用python脚本按照Msg格式对数据进行编解码。这里,我们使用的序列化代码如下:

该代码的就是将数据v持久化到传入的文件中,其中encode接口SerializeToString()是在编译处demo_pb2.py自动生成的,与之相对应的还有decode接口ParseFromString()。 在python write.py data之后,我们可以解析data的二进制格式:

在pb协议中,任何数据都是由key-value的形式管理,其中,key即message中每个字段的field_number, 除了key,还要有wire_type属性, field_number<<3|wire_type构成一个字节,对于有wire_type == length-delimited的数据,还需要一个字节描述数据长度的payload属性。 我们逐个字节解释这几行二进制:

Continue reading

每线程变量

每线程变量有多种实现方式,最原始的方式即是使用数组等数据结构建立线程ID和要访问变量之间的关系, 本文讨论如何借助其他工具来实现变量的每线程存储。

__thread

__thread是gcc扩展关键字,可以实现被声明变量的每线程存储。

输出的结果:

Continue reading

Linux 任务调度简介

吞吐 和 响应 是评估一个计算机系统性能的主要参数,比较典型的就是在IO系统中,Linux为了提高IO吞吐而将IO先缓存到内存中,这在本质上是牺牲了对某个IO的响应过程并以此来提高吞吐率。这种设计思路在Linux调度器的设计也可以看到,Linux并没有追求硬实时系统的高响应,而是在吞吐和响应之间取得一个平衡。

在Linux中,任务(或者说task_struct,由于Linux中进程和线程没有明显的界限,本文使用任务来描述一个可调度实例)一共有0-139个优先级,其中0-99是给RT 任务使用,100-140给普通任务使用,前者即实时调度策略,进一步又分为SCHED_FIFO和SCHED_RR,后者即普通任务,即SCHED_NORMAL。作为优先级更高的实时任务,SCHED_FIFO和SCHED_RR的任务一旦处于RUNNING(就绪)状态,就可以无条件抢占SCHED_NORMAL的CPU,而只要系统中有RT的任务在运行,SCHED_NORMAL就没有运行的机会。当然,即便是RT 任务,其也会有优先级高低的区别,对于不同优先级的RT任务,显然要严格按照高优先级优先运行的原则,而对于相同优先级的两个RT任务,如果使用SCHED_FIFO,就按照”先来后到”的顺序,只要先运行的任务没有放弃CPU,后来的任务不会被调度到; 如果使用SCHED_RR,同优先级的任务会”周期性”的都被执行到。

Linux中每一个SCHED_NORMAL任务都有一个nice值(-20~+19,缺省为0),这个nice值表示这个任务对于其他任务的”容忍”程度,nice值越大,表示这个任务越好说话,结果是这个任务占用的资源就会减少。早期的Linux通过动态的调整一个动态nice来平衡IO消耗型任务和CPU消耗型任务,不过如今这个工作已经被巧妙的CFS替代了

vruntime = pruntime/weight * delta

上图就是CFS的运作公式,pruntime表示当前任务实际运行的时间,weight是一个与nice值相关的权重,share是系统级/cgroup的调整参数。整个CFS基于红黑树实现进程调度。如下图所示:

上图的数字就是每个任务的vruntime,CFS在进行任务调度的时候,总是调度红黑树中vruntime最小的那个任务,由于IO消耗型的任务本身pruntime就比CPU消耗型的pruntime小,所以整体上IO消耗型的任务的vruntime更小,也就能够获得更多的被调度的机会,这样就能提高系统整体的吞吐。

Continue reading

Linux 内存管理 VI

内存统计是资源评估的重要方面,本文从进程和系统两个视角讨论smem和free两个常用的内存统计工具,Linux进程虚拟地址空间和物理内存的关系如下。

进程视角

进程内存消耗通常使用VSS、RSS、PSS、USS来表征,需要注意的是,进程的这些指标的统计是不包括内核地址空间的。
VSS = 1 + 2 + 3 virtual set size,进程的虚拟地址空间都是通过 task_struct->mm_struct ->mmap->vm_area_struct 来管理其虚拟地址空间,每一个VMA都对应一个Section,所有的VMA对应到进程的内存消5就是VSS,以下两点需要注意:

  1. VSS不是真的体现进程物理内存的消耗,而是虚拟地址空间的消耗。
  2. 对于共享库来说,代码段只有一份,数据段是数据调用进程的,每个进程一份

可以通过如下方式查看VSS(VSZ字段)

RSS = 4 + 5 + 6,resident set size,只进程实际占用的内存,对于Lib,其所占内存完整的体现在每一个调用的进程RSS中
PSS = 4/3 + 5/2 + 6, proportional set size,同样是体现进程实际占用的内存,不同是对于多进程公共的Lib,按照比例将其占用内存摊派到每一个进程的PSS中
USS = 6 unique set size ,进程独占的内存,比如进程的Heap,分析内存泄漏时通常使用USS。
可以通过如下方式查看USS、PSS、RSS,smem工具通过读取**/proc//smaps和maps**两个文件整理输入如下。

系统视角

Continue reading

Linux 内存管理 V

严格意义上讲,Linux并不严格区分进程和线程,众所周知,进程是资源分配的基本单位而线程是系统调度的基本单位,但是在Linux中,task_struct作为内核调度的实体,即一个线程,如果其使用的资源完全独立于其他task_struct,那就可以看作一个”进程”,如果其使用的资源完全是另一个task_struct,那就可以看作一个”线程”,如果其使用的资源部分是自己的,部分是其他task_struct的,那就可以看作一个”X程”,扯远了,下图中的task_struct表示一个进程。

当我们malloc一块内存的时候,本质上是调用brk()移动当前进程的堆指针,在内核中的表现为构建相应的虚拟内存结构vm_area_struct,这个vm_area_struct描述了一块虚拟地址空间,其中还保存着相应的权限信息,这些结构构建好之后变使malloc()返回,当用户去写这块分配出来的内存的时候,MMU会发现进程的PT并没有相应的映射表项,进而触发PageFault,内核的PageFault核心处理函数是do_page_fault(),这个函数会检查相应的vm_area_struct权限是否正确,如果检查通过,就会通过buddy分配器(注意不是slab,slab只给内核用)分配相应的内存并修改PT,如此这块虚拟地址空间才真正可用。

Linux 内存管理 IV

根据Memory Hierarchy,系统总是希望将活跃的数据放在更快(通常也更小)的存储介质中来提升系统IO性能,在当代计算机体系中,位于Memory Hierarchy顶端的就是CPU中、负责缓存主存数据的cache,其缓存算法由硬件实现,OS无法插手,但OS可以管理自主存开始剩下的所有的存储介质,基于同样的思想,OS也希望将活跃的数据放在所能控制的最快介质——主存中,将数据从慢介质移动到快介质的Cache replacement policy有很多种,Linux通常使用LRU作为指导思想进行设计,即假设”当前正在使用的有很大可能将来也能用到,而许久不用的将来被用到的可能性很低“。基于这样的思想,Linux中使用Page Fault机制将活跃的数据从Backing Device中缓存到更快的主存中。同样,基于LRU,Linux仍需要将不活跃的数据移出主存,这就是Memory Reclaim——内存回收。

Mapped Page/Anonymous Page

在Linux中,按照Page的background不同将内存页分为两类: Mapped Page和Anonymous Page。Mapped Page即映射页,也可以称之为File Cache、Page Cache,其实质是File-backed Page,file即文件系统中的”file”,比如执行a.out,每个ELF文件的格式头都会记录代码段的位置,内核将代码段映射到(可以理解为mmap)到page中,并赋予其R-X权限,这种Page以File为background,Dirty Flush或Swap Out时就以该位于Backing Device上的File为目标操作,可以看出,对于内核来说,文件就是一块内存。老版本的内核还将Mapped Page区分为两种形式:buffers用于缓存裸设备的数据、cached用于缓存文件系统下文件的数据,不过新版本已经不做区分。Anonymous Page即匿名页,指那些没有明确”文件背景”的page,比如一个进程的Stack、Heap以及COW生成的数据段,这些页在swap out的时候没有一个backed-file供其写入,需要写入swap分区/文件。如果从空间维度考察Swap,Mapped Page和Anonymous Page都是Swap回收的范围

Linux 内存管理 III

DRAM作为Memory Hierarchy 中除CPU内Cache外最快的一级,对于内核性能来说是稀缺资源,所以Linux内核对于物理内存相关的数据结构设计地十分精巧,比如常见的用于分配内存的buddy、Slab分配器、用于规整内存的KSM、用于快速回收内存的RMAP等等,无论哪种精巧的技术,都离不开本文需要讨论的几个与物理内存管理的基础类:node、zone和page。

node

多CPU的硬件架构可以分为两种:UMA和NUMA,前者由多个CPU共享一个内存控制器,这样主板上的所有内存对于所有的CPU都是一样的,缺点是内存控制器容易称为系统瓶颈。NUMA架构中每个CPU使用自己的DRAM Controller,即每个CPU都有接在自己本地的内存控制下的LOCAL内存区间和接在其他CPU内存控制器下的REMOTE内存,显然,对于任何一个CPU而言,访问LOCAL内核要比访问REMOTE的内存要快很多,这样的设计就造成了地址空间中一个物理地址使用不同的CPU的访问不同,即物理地址空间的不均匀,NUMA架构通常可以更好的发挥硬件性能,是当下服务器的主流架构,缺点是系统对内核的内存管理提出了新的挑战。

Continue reading

Linux 内存管理 II

本文主要讨论Linux对物理地址空间的分页机制的实现。

分页机制说来也简单,借助MMU来实现进程虚拟地址空间到物理地址空间的映射,在x86平台中,PGD的基地址存放在CR3寄存器,对于ARM平台,在TIBRx寄存器,每当切换进程上下文的时候,Linux都会将本进程的PGD基地址写入相应的寄存器,如此便更换了虚拟地址到物理地址的映射关系,其产生的结果的就是不同进程中的同一个虚拟地址,经过PageGlobleDentry->PageUpperDentry->PageMiddleDentry->PageTableEntry的映射,找到的是不同的物理页框pfn,也就找到了不同的物理地址。

上图是比较典型的32bit CPU中3级页表模型,在64bit视物理地址总线宽度的不同(eg ARM64采用48bit寻址)可能会采用更多级的页表,但其基本原来都是一样的。

MMU除了要完成相应地址的映射,还要进行权限检查,由于地址映射都是以页为单位的,所以权限检查也是以页为单位的。具体地,每种CPU的MMU支持的权限标志位可能都不一样,以ARM Linux为例,其使用在权限定义在arch/arm/include/asm/pgtable*.h中,包括标记页面是否在主存中的L_PTE_PRESENT、标记页面是否是脏页的L_PTE_DIRTY、以及页面是否可执行的PAGE_SHARED_EXEC等。当Linux触发Page Fault时,就会准备好平台相关的MMU表项结构:映射关系和权限,放在相应的页目录中以供MMU使用。

PHYS_OFFSET,PAGE_OFFSET

这两个宏是系统对于物理地址空间划分很常用的两个宏,由于内核的虚拟地址空间和物理地址空间存在线性映射的关系,通过这两个宏,就可以很快地完成转换

Macro含义备注
PHYS_OFFSETRAM偏移物理地址空间RAM的起始地址
PAGE_OFFSETkernel space偏移虚拟地址空间中内核地址空间的起始地址

pgd_t, pud_t, pmd_t

这几个变量的类型分别是PGD,PUD,PMD的基地址,主要注意的是,作为物理地址,它们的本质是unsigned long, 而不是指针。

*_SHIFT, *_MASK

这两类宏在内存管理也很常见,前者是用于计算相应空间的大小的对数,比如,对于页来说

类似的还有PMD_SHIFT,PUD_SHIFT,PGDIR_SHIFT等。

后者用于计算屏蔽低位的掩码。

ALIGN()

要解释这个宏,就需要看看x&~(2^n -1 )的计算结果。对于一个2的指数,其二进制假设为0x10000,则0x10000 – 1 = 0x01111, ~0x01111 = 0x1111111…0000,可见任何一个数x与该数做”与”运算,低位都会被掩掉,也就是向下对齐的效果,即

同理,对于向上对齐,我们只需要的使用(x+(a-1))&~(a-1)即可,这就是ALIGN。

ALLIGN(x,a)用于x以a为标准向上对齐的值,a必须是2的次方,定义如下:

Linux 内存管理 I

CPU的内存空间指由CPU地址总线张成的地址空间,即物理地址空间。这个空间中,借助地址总线,CPU可以进行”字节级”的寻址。

上图是一个主板硬件拓扑的例子(实际开发中用的可能完全不是这个样子,比如全闪主板通常直接从CPU中出SSD控制器),我们以该图为例说明内存管理的基本概念。

视不同架构的CPU,接入物理地址空间的硬件有所不同。对于ARM等RISC处理器,通常将内存条(物理内存),和设备、外设控制器一并接入物理地址空间。这样可以令CPU使用统一的方式访问物理内存和设备IO,这种设备IO方式称之为映射IO,此时,物理地址空间可以被看作分成两部分: 物理内存区间设备IO区间。而在x86体系中,设备IO不占用物理地址空间,intel的CPU为设备单独开辟了16bit的IO空间,在硬件上通过拉低/拉高作为flag的某个引脚或寄存器来区分一个地址是位于内存空间还是IO空间,表现在软件上就是在内核态中使用不同接口函数来操作不同的空间。

对映射IO来说,本质上是CPU将需要访问的地址发给北桥,由北桥决定这个地址是发给DRAM控制器来访问内存,还是将相应的指令发给某个设备。总之,CPU的物理地址空间布局和与之相应的操作方法是BSP级的。

在内核通过启动参数解析了BSP信息之后,CPU看到的就只是一个unified的地址空间,在开启了MMU的情况下,这个地址空间就是虚拟地址空间,否则就是物理地址空间。此外,需要注意的是,低端的DMA引擎通常不能实现对整个DRAM的寻址,这也就是内核将低端内存设置为ZONE_DMA的原因,此外,没有IOMMU的DMA引擎接收一次指令只能将一块连续的内存传递到二级存储介质,而配备了IOMMU的DMA引擎可以实现Scatter/Gatter,即IOMMU负责将DRAM中离散的地址区间映射为DMA引擎中的连续的地址。

其中,CPU与DRAM之间通过MMU连接进而实现虚拟地址到物理地址的转换,而设备通过IOMMU和DRAM连接进而实现IO总线地址到物理地址的转换。

iostat

iostat是一个基于/proc/diskstats文件计算的IO统计工具,它在周期性的去读取/proc/diskstats的IO统计信息并通过一定的公式计算出来。输出如下:

tps:transfer per second,每秒传输的IO数量
kB_read/s:每秒读取的KB数量
kB_wrtn/s:每秒写的KB数量
kB_read:累计读取的KB数
kB_wrtn:累计写的KB数量

rrqm/s:每秒合并读IO的次数
wrqm/s:每秒合并写IO的次数
r/s:每秒读IO完成的次数
w/s:每秒写IO完成的次数
rkB/s:每秒读IO的KB数量
wkB/s:每秒写IO的KB数量
avgrq-sz:平均IO扇区数
avgqu-sz:平均未完成的IO数量,包括在队列中的和硬件正在处理的。
await:平均每个IO耗时,包括在队列中和硬件操作的时间。
r_wait:平均每个读IO耗时**w_wait**:平均每个写IO耗时
svctm:Warning! Do not trust this field any more. This field will be removed in a future sysstat version
%util:磁盘利用率,只要有IO操作就算,由于硬件的并发处理IO能力,即使%util达到100%也不意味着已达存储介质的性能极限,只能说明在这个统计周期内,该介质一直都有IO操作。

/proc

/proc/diskstats

Linux的/proc存储了很多系统级统计信息,与IO相关的信息都存储在diskstat中,输出如下

一共11个字段,在内核文档””中有详细的解释,除了Field9之外,其他字段都是从开始计数的累计值。以sda为例,前两个分别是设备的major和minor,接下来分别是:

Field 1 – rd_io,表示120880个读IO被完成
Field 4 – rd_ticks,读操作一共耗时78832ms,从__make_request开始,到end_that_make_request()为止,包括在队列中等待的时间。
Field 5 – wr_io,表示53591个写IO被完成
Field 6 – wr_merges,表示发生了51267次写IO合并
Field 7 – wr_sectors,表示2315680个被写的扇区
Field 8 – wr_ticks,写操作一共耗时115900ms,
Field 9 – in_flight,IO队列中遗留了0个IO尚未处理,当有一个IO进入IO队列时,该值+1,每完成一个IO,该值-1
Filed 10 – io_ticks,表示处理IO花费了76384ms的自然时间,即只要in_flight不为0,io_ticks就在计数。io_ticks不是rd_ticks和wr_ticks的和,因为rd_ticks和wr_ticks是针对IO而言的,由于设备往往有并行处理IO的能力,所以io_ticks往往小于rd_ticks+wr_ticks。
Field 11 – time_in_queue,系统一共花费了194700ms用于IO操作
Field 2 – rd_merges,表示发生了392次读IO合并
Field 3 – rd_sectors,表示8323045个被读取的扇区

Linux VFS V

一个文件系统获得了内核的支持,仅仅是功能上的可能性,如果要使用,还需要将其mount到rootfs下,才能可见并进而被使用,本文主要讨论mount的实现过程。

bdev文件系统在其初始化时就已经挂载到内核。

Linux VFS IV

在Linux内核中,一个文件系统包括两部分,一部分是位于磁盘上的管理数据,即文件系统的本体,另一部分是位于内核中的代码,即对VFS中相应接口的实现,没有这部分实现,内核无法识别该文件系统,如此mount、读写等操作也就无从谈起了。

向内核中添加一个文件系统的支持,与其实例无关,即两个分区都是ext4文件系统,但内核对于ext4的支持的代码只需要执行一次,而且,此时也没有从磁盘读取任何数据,这是mount的工作。

本文主要整理内核对各个文件系统的初始化代码

devtmpfs

sysfs

proc

ext4

bdev

注意到bdev文件系统在初始化时已经挂载

Linux VFS III

open()的最终目的是:准备好file以及依赖的数据结构。为此,open()主要做了以下几方面工作:

  1. PathWalk找到目标文件
  2. 构造并初始化inode
  3. 构造并初始化file

其中,PathWalk已经讨论过,本文主要聚焦inode和file的构造和初始化