本文深入探讨了C++并发编程中的关键技巧,包括使用std::lock
和std::lock_guard
避免死锁、通过固定顺序获取锁防止死锁、利用std::unique_lock
实现灵活的锁管理,以及使用std::call_once
实现线程安全的延迟初始化。文章还介绍了如何通过std::condition_variable
实现线程间的数据等待与通知机制,展示了线程安全队列的实现。这些技巧为C++开发者提供了高效处理并发问题的实用工具,帮助编写更安全、更高效的多线程程序。
LRU的简单实现
本文介绍了最近最少使用缓存(LRU)的简单实现。LRU缓存通过键值对存储数据,并在达到容量上限时删除最近最少使用的项目。文章详细展示了如何使用C++实现LRU缓存,包括get
和put
操作的核心逻辑。通过std::list
和std::unordered_map
的结合,实现了高效的数据访问和更新。本文为开发者提供了LRU缓存的实用实现方法,适合需要高效缓存管理的场景。
C++ 跳表简单实现
本文介绍了跳表(Skiplist)的简单实现,跳表是一种高效的数据结构,支持快速查找、插入和删除操作。文章详细展示了跳表的C++实现代码,包括节点结构定义、搜索、添加和删除操作的逻辑。通过随机层级生成和多层链表结构,跳表在保持简单性的同时,提供了接近平衡树的性能。本文为C++开发者提供了跳表的实用实现方法,适合需要高效数据管理的场景。
C++ 回调函数示例
C++ 回调函数示例
简单示例
1 | #include <functional> |
输出:
Enter: callback()
print()
Leave: callback()
接下来我们把这两个函数放入类中实现,在调用的时候绑定函数名和其对应实例就可以按以上例子方法调用。
1 | #include <functional> |
现在我们把绑定函数对象的过程封装起来
1 | #include <functional> |
《Go语言学习笔记》读书笔记(7)数据结构
数据结构
字符串
字符串是不可变字节(byte
)序列,其本身是一个符合结构.
1 | type stringStruct struct { |
头部指针指向字节数组,但没有NULL
结尾。默认以UTF-8
编码存储Unicode
字符,字面量里允许使用十六进制、八进制和UTF
编码格式。
内置函数
len
返回字节数组长度,cap
不接受字符串类型参数。
字符串默认值不是nil
, 而是""
.
使用for
遍历字符串是,分byte
和rune
两种方式。
1 | func main () { // byte |
要修改字符串,须将其转换为可变类型([]rune
或[]byte
), 待完成后再转换回来。但不管怎么转换,都须重新分配内存,并复制数据。
1 | func pp(format string, ptr interface{}) { |
编译器会为了某些场合进行专门优化,避免额外分配和复制操作:
- 将
[]byte
转换为string key
, 去map[string]
查询的时候。 - 将
string
转换为[]byte
, 进行for range
迭代时,直接取字节赋值给局部变量。
除了类型转换外,动态构建字符串也容易造成性能问题。
用加法操作符拼接字符串时,每次都须重新分配内存。如此,在构建超大字符串时,性能就显得极差。
改进思路时预分配i足够大的空间。常用方法是用string.Join
函数,他会统计所有参数长度,并一次性完成内存分配操作。
另外
utf8.ValidString(s) 返回s是不是一个有效的字符串
utf8.RuneCountInString(s) 替代len
返回unicode
的字符数量
《Go语言学习笔记》读书笔记(6)方法
方法
方法是与对象实例绑定的特殊函数。
方法是面向对象编程的基本概念,用于维护和展示对象的自身状态。对象是内敛的,每个实例都有各自不同的独立特征,以属性和方法来暴露对外通信接口。普通函数则专注于算法流程,通过接收参数来完成特定逻辑运算,并返回最终结果。换句话说,方法是有关联的而函数通常没有。
方法和函数定义语法区别,在于前者有前置实例接收参数,编译器以此确定方法所数类型。
可以为当前包,以及除接口和指针以外的任何类型定义方法。
1 | type N int |
方法同样不支持重载(overload
)。receiver
参数名没有限制,按惯例会选用简短有意义的名称。如方法内部并不引用实例,可省略参数名,仅保留类型。
1 | type N int |
方法可看作特殊的函数,那么receiver
的类型自然可以是基础类型或指针类型。这会关系到调用时对象实例是否被赋值。
1 | type N int |
可使用实例值或指针调用方法,编译器会根据方法receiver
类型自动在基础类型和指针类型间转换。
1 | func main() { |
指针类型的receiver
必须时合法指针(包括nil
), 或能获取实例地址。
1 | type X struct {} |
如何选择方法的receiver
类型:
- 要修改实例状态,用
*T
- 无需修改状态的小对象或固定值,建议用
T
- 大对象建议用
*T
, 以减少复制成本。 - 引用类型、字符串、函数等指针包装对象,直接用T。
- 若包含
Mutex
等同步字段,用*T
,避免因复制造成锁操作无效 - 其他无法确定的情况,都用
*T
匿名字段
可以访问匿名字段成员那样调用其方法,有编译器负责查找。
1 | type data struct { |
方法也会有同名遮蔽问题。但利用这一特性可实现类似(override
)操作。
1 | type user struct {} |
尽可能直接访问匿名字段的成员和方法,但他们依然不属于继承关系。
方法集
类型有一个与之相关的方法集,这决定了它是否实现某个接口。
- 类型
T
方法集包含所有receiver T
方法。 - 类型
*T
方法集包含所有recever T
+*T
方法。 - 匿名嵌入
S
,T
方法集包含所有receiver S
方法。 - 匿名嵌入
*S
,T
方法集包含所有receiver S
+*S
方法。 - 匿名嵌入
S
或*S
,*T
方法集包含所有receiver S
+*S
方法。
可利用反射测试这些规则。
1 | type S struct{} |
方法集影响接口实现和方法表达式转换,于通过实例或实例指针调用方法无关。实例并不使用方法集,而是直接调用(或通过隐式字段名).
很显然,匿名字段就是为方法准备的。否则,完全没必要为少写个字段名而大费周折。
面向对象的三大特征"封装",“继承"和"多态”, Go仅仅实现了部分特征,它更倾向于”组合优于继承“这种思想。将模块分解成相互独立的更小单元,分别处理不同方面的需求,最后以匿名嵌入方式组合到一起,共同实现对外接口。而且其简短一致的调用方式,更是隐藏了内部实现细节。
组合没有父子依赖,不会破坏封装。且整体和局部松耦合,可任意增加来实现实现扩展。各单元持有单一职责,互无关联,实现和维护更加简单。
表达式
方法和函数一样,除直接调用外还可以赋值给变量,或作为参数传递。依照具体引用方式不同,可分为expression
和value
两种状态。
通过类型引用Method expression
会被还原为普通函数央视,receiver是第一参数,调用时须显示传参。至于类型,可以是T
或*T
, 只要目标方法集中即可。
1 | type N int |
当然,也可直接以表达式方式调用。
1 | func main(){ |
基于实例或指针引用的method value
, 参数签名不会改变,依旧按正常方式调用。
但当method value
被赋值给变量或作为参数传递时,会立即计算并复制该方法执行所需的receiver
对象,与其绑定,以便在稍后执行时,能隐式传入receiver
参数。
1 | type N int |
编译器会为method value生成一个包装函数,实现间接调用。至于
receiver
复制,和闭包的实现方法基本相同,打包成funcval
, 经由DX
寄存器传递。
当method value
作为参数是,会复制含receiver
在内的整个method value
。
1 | func call(m func()) { |
当然,如果目标方法的receiver
是指针类型,那么被复制的仅是指针。
1 | type N int |
只要receiver参数类型正确,使用nil
同样可以执行。
1 | type N int |
C++ 二叉树的遍历
前序遍历:
遍历的顺序是:根节点-左节点-右节点
递归代码:
1 | class Solution { |
迭代代码:
1 | class Solution { |
中序遍历:
遍历的顺序是:左节点-根节点-右节点
递归代码
1 | class Solution { |
迭代代码
1 | class Solution { |
后序遍历:
遍历的顺序是:左节点-右节点-根节点
递归代码
1 | class Solution { |
迭代代码
1 | class Solution { |
C++ 算法题解
排序操作次数
题目描述:
有一种排序算法定义如下,该排序算法每次把一个元素提到序列的开头,例如2, 1, 3, 4,只需要一次操作把1提到序列起始位置就可以使得原序列从小到大有序。现在给你个乱序的1-n的排列,请你计算最少需要多少次操作才可以使得原序列从小到大有序。
输入描述
输入第一行包含两个正整数n,表示序列的长度。(1 <= n <= 100000)
接下来一行有n个正整数,表示序列中的n个元素,中间用空格隔开。(1 <= a_i <= n)
输出描述
输出仅包含一个整数,表示最少的操作次数。
样例输入
4
2 1 3 4
样例输出
1
1 | #include <algorithm> |
《Go语言学习笔记》读书笔记(5)接口
接口
接口代表一种调用契约,是多个方法声明的集合。接口最常见的使用场景,是对包外提供访问,或预留扩展空间。
Go
接口的实现机制很简洁,只要目标类型方法集内包含接口声明的全部方法,就被视为实现了该接口,无须做显式声明。当然,目标类型可实现多个接口。
接口:
- 不能有字段
- 不能定义自己的方法
- 只能声明方法,不能实现
- 可嵌入其他接口类型
接口通常以er
作为名称后缀,方法名是声明组成部分,但参数名可不同或省略。
1 | type tester interface { |
如果接口没有任何声明方法声明,那么就是一个空接口, 他的用途类似面向对象的根类型Object
, 可被赋值为任何类型的对象。
接口变量默认值是nil
。如果实现接口的类型支持,可做相等运算。
1 | func main() { |
可以像匿名字段一样,嵌入其他接口。目标类型方法集中必须拥有包含嵌入接口方法在内的全部方法才算实现了该接口。
前提是,不能有同名方法, 不能嵌入自身或循环嵌入,那会导致递归错误。
1 | type stringer interface { |
超集接口变量可隐式转换为子集,反过来不行。
1 | func pp(a stringer) { |
支持匿名接口类型,可直接用于变量定义,或作为结构字段类型。
1 | type data struct{} |
执行机制
接口执行一个名为itab
的结构存储运行期所需的相关类型信息。
1 | type iface struct { |
相关类型信息里保存了接口和实际对象的元数据。同时itab
还用fun
数组(不定长结构)保存了实际方法地址,从而实现在运行期对目标方法的动态调用。
除此之外,接口还有一个重要特征:将对象赋值给接口变量时,会复制该对象。我们甚至无法修改结构存储的复制品,因为它也是unaddressable
的。
1 | func main() { |
即便将其复制出来,用本地变量修改后,依然无法对iface.data
赋值。解决方法就是将对象指针赋值给接口,那么接口内存存储的就是指针的复制品。
只有当接口变量内部的两个指针(itab
, data
)都为nil
时, 接口才等于nil
.
类型转换
类型推断可将接口变量还原为原始类型,或用来判断是否实现了某个更具体地接口类型。
1 | type data int |
使用ok-idiom
模式,即便转换失败也不会引发panic
。还可用switch
语句在多种类型间做出推断匹配,这样空接口就有更多发挥空间。
1 | func main() { |
提示:
type switch
不支持fallthrought
技巧
让编译器检查,确保类型实现了指定接口
1 | type x int |
定义函数类型,让相同签名地函数自动实现某个接口
1 | type FuncString func() string |
《Go语言学习笔记》读书笔记(4)并发
并发
- 并发: 逻辑上具备同时处理多个任务的能力。
- 并行: 物理上在同一时刻执行多个并发任务。
多线程或多进程时并行的基本条件,但单线程也可用协程做到并发。尽管协程在单个线程上通过主动切换来实现多任务并发,它也有自己的优势。除了将因阻塞而浪费的时间找回来以外,还免去了线程切换的开销。协程上运行的多个任务本质上是依旧串行的,加上可控自主,所以并不需要做同步处理。
通常情况下,用多进程来实现分布式和负载平衡,减轻单进程垃圾回收压力;用多线程抢夺更多的处理器资源。用协程来提高处理器时间片利用率。
简单将goroutine
归纳为协程并不合适。运行时创建多个线程来执行并发任务,且任务单元可被调度到其他线程并行执行。这更像是多线程和协程的综合体,能最大限度提升执行效率,发挥多核处理能力。
只须在函数调用前添加go
关键字即可创建并发任务。
1 | go println("hello, world!") |
关键字go
并非执行并发操作,而是创建一个并发任务单元。新建任务被放置在系统队列中,等待调度器安排合适系统线程去获取执行权。当前流程不会阻塞,不会等待该任务启动,且运行时也不保证并发任务的执行次序。
每个任务单元除保存函数指针、调用参数外,还会分配执行所需的栈内存空间。相比系统默认MB
级别的线程栈,goroutinue
自定义栈初始仅须2 KB
,所以才能创建成千上万的并发任务。自定义栈采取按需分配策略,在需要时进行扩容,最大能到GB
规模。
与defer
一样,gorountine
也会因"延迟执行"而立即计算并复制执行参数。
1 | package main |
Wait
进程退出时不会等待并发任务结束,可用管道(channel
)阻塞,然后发出退出信号。
1 | func main() { |
除关闭通道外,写入数据也可解除阻塞。
如要等待多个任务结束,推荐使用sync.WaitGroup
。通过设定计数器,让每个goroutine
在退出前递减,直至归零时接触阻塞。
1 | func main() { |
尽管WaitGroup.Add
实现了原子操作,但建议在goroutine
外累加计数器,以免Add
尚未执行,Wait
已经退出。
可在多处使用Wait
阻塞,他们都能接收到通知
1 | func main() { |
GOMAXPROCE
运行时可能会创建很多线程,但任何时候仅有限的几个线程参与并发任务执行。该数量默认与处理器核数相等,可用runtime.GOMAXPROCS
函数(或环境变量)修改。
Local Storage
与线程不同,goroutine
任务无法设置优先级,无法获取编号,没有局部存储(TLS), 甚至连返回值都会被抛弃。但除优先级外,其他功能都很容易实现。
1 | func main() { |
如使用
map
作为局部存储容器,建议做同步处理,因为运行时会对其做并发读写检查。
Gosched
暂停,释放线程去执行其他任务。当前任务被放回队列,等待下次调度时恢复执行。
1 | func main() { |
Goexit
Goexit
立即终止当前任务,运行时确保所有已注册延迟调用被执行。该函数不会影响其他并发任务,不会引发panic
, 自然也就无法捕获。
1 | func main() { |
如果在main.main
里调用Goexit
, 它会等待其他任务结束,然后让进程直接崩溃。
无论身处哪一层,Goexit
都能立即终止整个调用堆栈,这与return
仅退出当前函数不同。标准库函数os.Exit
可终止进程,但不会执行延迟调用。
通道
Go
语言并未实现严格的并发安全。
允许全局变量、指针、引用类型这些非安全内存共享操作,就需要开发人员自行维护数据一致和完整性。Go
鼓励使用CSP
通道,以通信来代替内存共享,实现并发安全。
CSP: Communicating Sequential Process
通过消息来避免竞态的模型除了CSP
, 还有Actor
。但两者由较大区别
作为CSP
核心,通道(channel)是显式的,要求操作双方必须知道数据类型和具体通道,并不关心另一端操作者身份和数量。可如果另一端未准备妥当,或消息未能及时处理时,会阻塞当前端。
相比起来,Actor
是透明的,它不在乎数据类型及通道,只要知道接收者信箱即可。默认就是异步方式,发送方对消息是否被接收和处理并不关心。
从底层实现上来说,通道知识一个队列。同步模式下,发送和接受双方配对,然后直接赋值数据给对方。如配对失败,则置入等待队列,直到另一方出现后才被唤醒。异步模式抢夺的则是数据缓冲槽。发送方要求有空槽可供写入,而接收方则要求有缓冲数据可读。需求不符时,同样加入缓冲队列,直到有另一方写入数据或腾出空槽后被唤醒。
除传递消息(数据)外,通道还常被用做事件通知。
1 | func main() { |
同步模式必须有配对操作的goroutine
出现,否则会一直阻塞。而异步模式在缓冲区未满或数据未读完前,不会阻塞。
1 | func main() { |
多数时候,异步通道有助于提升性能,减少排队阻塞。
缓冲去大小仅是内部属性,不属于类型组成部分。另外通道变量本身就是指针,可用相等操作符判断是否为同一对象或nil
。
内置函数cap
和len
返回缓冲区大小和当前已缓冲数量;而对于同步通道则都返回0;据此可判断通道时同步还是异步
1 | func main () { |
收发
除使用简单的发送和接受操作符外,还可用ok-idom
或range
模式处理数据
1 | func main() { |
一次性事件用
close
效率更好,没有多余开销。连续或多样性事件,可传递不同数据标志实现。还可使用sync.Cond
实现单播或广播事件。
对于closed
或nil
通道,发送和接收操作都有相应规则:
- 向已关闭通道发送数据,引发
panci
- 从已关闭接收数据,返回已缓冲数据或零值。
- 无论收发,
nil
通道都会阻塞。
1 | func main() { |
重复关闭或关闭nil
通道都会引发panic
错误。
单向
通道默认时双向的,并不区分发送和接收端。
尽管可用make
创建单向通道,但那没有任何意义。通常使用类型装欢来获取单向通道,并分别赋予操作双方。
1 | func main() { |
不能在单向通道上做逆向操作。
1 | func main(){ |
同样,close不能用于接收端
1 | func main() { |
无法将单向通道重新转换回去。
1 | func main() { |
选择
如要同时处理多个通道,可选用select
语句。它会随机选择一个可用通道做收发操作。
1 | func main() { |
如要等全部通道消息处理结束(closed),可将已完成通道设置为nil
。这样它就会被阻塞,不再被select
选中。
即使是同一通道,也会随机选择case
执行。
当所有通道都不可用时,select
会执行default
语句。如此可避开select
阻塞,但须注意处理外层循环,以免陷入空耗。
模式
通常使用工厂方法将goroutine
和通道绑定。
1 | type receiver struct { |
鉴于通道本身就是一个并发安全的队列,可用作ID generator
、Pool
等用途。
1 | type pool chan []byte |
用通道实现信号量(semaphore)
1 | func main() { |
标准库time
提供了timeout
和tick channel
实现。
1 | func main() { |
捕获INT
、TERM
信号,顺便实现一个简易的atexit
函数。
1 | import ( |
资源泄露
通道可能会引发goroutine leak
, 确切的说,是指goroutine
处于发送或接收阻塞状态,但一直未被唤醒。垃圾回收器并不收集此类资源,导致他们会在等待队列里长久休眠形成资源泄露。
同步
标准库sync
提供了互斥和读写锁,另有原子操作等,可基本满足日常开发需要。Mutex
、RWMutex
的使用并不复杂,只有几个地方需要注意。
将Mutex
作为匿名字段时,相关方法必须实现为pointer-receiver
, 否则会因赋值导致锁机制失效。
1 | type data struct { |
- 锁不支持递归锁。
- 对性能要求较高时,应避免使用
defer Unlock
- 读写并发时,用
RWMutex
性能会更好一些 - 对单个数据读写保护,可尝试用原子操作
- 执行严格的测试,尽可能打开数据竞争检查