关于多线程学习的笔记

task: implement Observer and Observable, read shared_ptr

base 库

1. base/StringPiece.h

 

Viewpoint

1. 判断一个指针是不是合法指针没有高效的方法,这是C/C++指针问题的根源。在Java中,一个reference只要不为null,它一定指向有效地对象

2. composition是x是owner的直接数据成员,association和aggregation则只是用到了另一个对象,表现在持有另一个对象的指针或引用

3. 引入另外一层间接层,用对象来管理共享资源,即handle/body惯用技法,shared_ptr的精妙之处。

4. C++利用智能指针达成的效果是:一旦某对象不再被引用,系统刻不容缓,立即回收内存,这通常发生在关键任务完成后的清理时期,不会影响关键任务的实时性,同时内存所有的对象都是有用的,绝对没有垃圾空占内存。

5. 在现代C++程序中一般不会出现delete语句,资源都是通过对象来管理,不需要程序员还为此操心

6. 建议尽量减少使用跨线程的对象,用流水线,生产者消费者,任务队列这些有规律的机制,最低限度共享数据,这是我所知最好的多线程编程的建议了。

7.shared_ptr是值语义,小心意外延长对象的生命期,weak_ptr是shared_ptr的好搭档,可以用作弱回调,对象池等

8.RAII(资源获取即初始化):每一个明确的资源配置动作(例如new)都应该在单一语句中执行,并在该语句中立刻将配置获得的资源交给handle对象(例如shared_ptr),程序中一般不出现delete

9.shared_ptr是管理共享资源的利器,需要避免循环引用,通常的做法是owner持有child的shared_ptr,child持有指向owner的weak_ptr。

10. 弱回调的语意是如果对象还活着,就调用它的成员函数,否则忽略之。使用weak_ptr指向对象,这样对象的生命期就不会延长。

11. 垃圾回收:没有垃圾回收的并发编程时困难的,因为指针算术的存在,C/C++里实现全自动垃圾回收更加困难,而那些天生具备垃圾回收的语言在并发编程方面具有明显的优势,Java是目前支持并发编程最好的主流语言,它的util.concurrent和内存模型是C++11效仿的对象。

12. <C++沉思录>作者的建议是避免使用指针,这本书详细介绍了handle/body idiom,这是编写大型C++程序的必备技术,也是实现物理隔离的法宝,值得细读

13. 并发编程有两种模型,一种是message passing,另一种是shared memory,在分布式中,运行在多台机器上的多个进程的并行编程只有一种实用模型message passing,在单机上,我们可以照搬,这样整个分布式系统的架构的一致性很强,扩容(scale out)起来也较容易。在多线程模型中,message passing更容易保证程序的正确性。

14. 线程同步的四项原则:1.尽量最低限度地共享对象 2. 使用高级并发编程构件:TaskQueue,生产者消费者模型,CountDownLatch 3.只使用非递归的互斥器和条件变量 4. 除了原子整数外,不要去用lock free算法,更不要想当然地发明同步设施

15.在没有实测数据支持的情况下,妄谈哪种做法效率更高时靠不住的,不能听信传言或凭感觉优化,很多人误认为用锁会让程序变慢,其实真正影响性能的不是锁,而是锁争用(lock contention),在程序的复杂度和性能之间取得平衡,并考虑未来两到三年扩容的可能。在分布式系统中,多机伸缩性比单机的性能优化更值得投入精力。

16. Unix的signal在多线程下的行为比较复杂,一般要靠底层的网络库(如Reactor)加以屏蔽,避免干扰上层应用程序的开发

17. 互斥器是使用最多的同步原语,它保护了临界区,任何一个时刻最多只有一个线程在此Mutex划出的临界区活动,目的是为了保护共享数据,使用的原则:1. 用RAII封装创建,销毁,加锁,解锁这四个操作,2.不手动调用lock和unlock,这种做法称为scoped locking,3.只用非递归的mutex(即不可重入的mutex)4.每次构造时,思考一路上持有的锁,防止因加锁顺序不同导致死锁。两个task如果加锁顺序一样是不会发生死锁的。(智能指针可以避免析构函数在对象仍在使用的时候被调用,减少了死锁的可能,但不能完全避免)

18,mutex分为递归锁(recursive)和非递归锁(non-recursive),也较可重入和非可重入。递归的就是可重入,即可以重复加锁的意思。而对非递归锁重复加锁会立即导致死锁。

19. 互斥器是加锁原语,用来排他性地访问共享数据,它不是等待原语。如果需要等待某个条件成立,我们应该使用条件变量。条件变量只有一种正确使用的方式:1. 必须与mutex一起使用,该布尔表达式受此mutex保护 2.在mutex已上锁的时候才能调用wait(). 3. 把判断布尔条件和wait()放到while循环中。

20. 学习多线程编程面临的最大的思维方式的转变有两点:1. 当前线程可能随时被切换出去,或者说被抢占了 2. 多线程程序中事件的发生顺序不再有全局统一的先后关系。

21. 用thread,mutex,condition这三样东西就已经构成了多线程编程的全部必备原语,多线程编程的难点不在于学习线程原语,而在于理解多线程与现有C/C++库函数和系统调用的交互关系,以进一步学习如何设计并实现安全且高效的程序。

22. 编写线程安全程序的一个难点在于线程安全是不可组合的,一个函数调用两个线程安全的函数,而这个函数本身很可能不是线程安全的。我们可以遵循一个基本原则:凡是非共享的对象都是彼此独立的,如果一个对象从始至终只被一个线程用到,那他就是安全的。另一个事实标准是:共享的对象的read-only操作时安全的,前提是不能有并发的写操作。例如两个线程各自访问自己的局部vector对象是安全的,同时访问共享的const vector对象也是安全的,但是这个vector不能被第三方修改。一旦有writer,那么read-only的操作也要加锁。

23. C++的标准库容器和std::string都不是线程安全的。C++标准库中的绝大多数泛型算法是线程安全的,因为这些都是无状态纯函数。C++的iostream不是线程安全的。是不是线程安全?看这个实现是否访问了全局共享的对象,并且那个对象是否会被改动。

24. 在Linux上,我建议使用gettid系统调用的返回值作为线程id,但是每次都执行一次系统调用似乎有些浪费,采用的办法是__thread变量来缓存gettid的返回值,这样只在本线程第一次调用的时候才进行系统调用,以后都是在从thread local缓存的线程id拿到结果。

25. 我认为如果能做到“程序中线程的创建最好能在初始化阶段全部完成”,则线程是不必销毁的,伴随进程一直运行,彻底避开了线程安全退出可能面临的各种可能。

26.按照我的观点,不应该从外部杀死线程。因为强行终止线程的话,它没有机会清理资源,也没有机会释放已经持有的锁,其他线程如果再想对同一个mutex加锁,那么就会立刻死锁。

27.安全退出一个多线程程序并不是一件容易的事,何况这里还没有设计如何安全地退出其他正在运行的程序,这需要精心设计共享对象的析构顺序,防止在各个线程退出时访问已失效的对象。在编写长期运行的多线程服务程序时,可以不必追求安全的退出,而是让进程进入拒绝服务的状态,然后就可以直接杀掉了。

28. __thread的使用规则:只能用于修饰POD类型,不能修饰class类型,只能用于修饰全局变量,函数内的静态变量,另外__thread变量的初始化只能用编译期常量。__thread变量时每个线程有一份独立实体,各个线程的变量值互不干扰,他可以用来修饰“值可能会变,带有全局性,但是又不值得用全局锁保护”的变量。

29. 我认为多线程应该遵循的原则是:每个文件描述符只由一个线程操作,从而轻松解决消息收发的顺序性问题,也避免了关闭文件描述符的各种race condition。一个线程可以操作多个文件描述符,但不能操作别的线程拥有的文件描述符。epoll也遵循相同的原则,我们应该把对同一个epoll fd的操作(添加,删除,修改,等待)都放到同一个线程中执行。

30. POSIX每次打开新文件(含socket)使用当前最小可用的文件描述符,这种分配方式稍不注意就会造成串话。解决的办法很简单:RAII。用socket对象包装文件描述符,所有对此文件描述符的读写都通过此对象进行,在对象的析构函数里关闭描述符。这样只要这个socket对象还活着,就不会有其他socket对象跟它有一样的文件描述符。

31. 在多线程程序中,使用signal的第一原则是不要使用signal,包括不要用signal作为IPC的手段,不要使用底层是signal机制的定时函数,不主动处理各种异常信号(有一个例外,主动忽略SIGPIPE),在没有别的方法的情况下,把异步信号转换为同步的文件描述符的事件(通过pipe)

32. 我个人遵循的多线编写的原则如下:1.线程是宝贵的 2. 线程的创建和销毁是有代价的 3. 每个线程应该有明确的职责 4. 线程间的交互应该尽量简单 5. 要预先考虑清楚一个mutable shared对象将会暴露给哪些线程

33. 一个多线程进程最好只写一个日志文件。用一个背景线程负责收集日志消息,并写入日志,其他线程只管往这个日志线程发送日志消息,这称为异步日志。

34. 高级语言(JAVA,Python)等的sockets库也没有对sockets API提供更高层的封装,直接编写网络程序很容易掉到陷阱里。我们需要一个好的网络库来降低开发难度。网络库的价值还在于能方便地处理并发连接。

 

 

实践:

1. 对象和线程的关系?

一个对象通过它的方法去创建一个线程,并把自己的依附到这个线程上(通过把自己的指针作为参数传给线程)。而这个对象的创建线程同样可以操作这个对象。这样,这个对象就可以被两个线程共享。如异步Log的线程就是如此。

2. 临界区的放什么代码?

只放O(1)的代码,运行时间必须是常数。另外在互为临界区的代码,可以通过在后端临界区为前端临界区准备一些资源,这样在前端临界区可以减少分配资源的时间,缩短前端临界区的长度,从而提高前端的业务响应速度。

3. 两个线程双缓冲的实现?

一个线程往缓冲区放东西,另一个线程从缓冲区读东西。缓冲区设计两个,都是“东西”的数组。通过互斥量,前端在写的时候,后端不能动;后端在写的时候,前端不能动。后端写的时候是把缓冲区和前端用的直接swap后来写文件。临界区的是很小的。后端使用带定时器的条件变量可以比较方便让后端处于一个轮询的模式

4. 多线程如果生产者速度高于消费者速度怎么办?

因为不能向阻塞IO那样自动限制前端的写入速度,异步情况下生产者速度长期高于消费者速度这样会造成数据在内存中堆积,严重时引发性能问题(可用内存不足)或程序崩溃(分配内存失败),可以简单处理:在消费者一端把“东西”丢掉,加快消费者处理速度。

5. 如果两个线程争用某个资源,如果降低锁竞争?

用shared_ptr管理这个资源,采用copy-on-write技术,当A线程在检查到B线程在用这个资源时(!shared_ptr::unique),A拷贝一份资源。而B线程当次的资源用完后自动被shared_ptr释放,下次使用资源需要重新get. 注意get函数要加锁,保证shared_ptr构造函数与unique判断同步。

 

问题:

1. 为什么析构函数线程安全很难用锁实现?

因为在析构函数中加锁的话,加完锁后,这个锁会被析构函数作为成员变量销毁;如果在销毁前另一个线程恰好阻塞在这个锁上,那那个线程的行为时不可预测的。这种情况下用外部锁才行。

2. 使用对象池预分配对象有什么问题?

要考虑对象池的线程安全,怎样保证线程A认为对象x已经放回了,而对象B认为x还活着;把多线程的操作串行化后降低性能;

3.如果对象x注册了任何非静态成员函数回调,那么必然在某处持有了指向x的指针,这就暴露在race condition之下,典型场景是Observer模式,这种情况下如何保证x对象还活着?

那就是不要持有x的原始指针,需要持有的是能告诉你这个指针指向的对象是否依然存活的智能指针

4. 如果两个对象都需要知道对象是否存活,那会导致智能指针的循环引用,导致资源泄漏,怎么办?

另一种情况是在对象的析构函数里面去让别的对象销毁它的智能指针,这样会意外延长对象的生命周期,必须使用弱的智能指针才能减少这种问题。

bind回调是一种延长生命期的可能性,如果持有了某一个回调,相当于持有了一份对象的智能指针

5. 如果防止C++中可能的各种内存问题

a. 缓冲区溢出:用vector和string,它们会记住缓冲区的长度

b. 空悬指针和野指针:用shared_ptr/weak_ptr,都能判断对象是否存在

c. 重复释放:用scoped_ptr,只在对象析构的时候释放

d. 内存泄漏:用scoped_ptr,对象析构的时候自动释放

e. 不配对的new[]/delete, 使用vector/scopped_array

scoped_ptr不能复制,不能移动,注定了只能在一个scope ({}或对象生命期)内存活,很好地诠释ownership,这个和uniqe_ptr类似,但是unique_ptr可以移动,意味着ownership可以转移,但是只有一份。

5. 可重入锁有什么问题?

你拿到一个锁以为就能修改对象了,没想到外层代码已经拿到了锁,正在修改。那这个所谓的锁还有什么意义。它的作用就是重复加锁,然后一层层解锁。代码虽然简单了,但容易隐藏问题。如果我们想要一个不加锁的版本,因为我们知道进入这个函数时已经加过锁了,可以加一个assert(mutex.islocked...())就是判断这个函数必须是在外面加过锁了才能使用。

6. 遍历vector同时又想修改vector时,应该怎么做?

一种方法是把修改推后,遍历完之后再修改;二是copy-on-write

7. 参数传递什么时候用指针,什么时候用引用?

使用引用传递,一般是立刻会进行复制保存,所以参数传递的时候用引用过渡;用指针则指不需要复制的情况,只是做保存用

8. 类成员什么时候用智能指针,什么时候用裸指针?

用裸指针的情况是,某个对象是栈对象,比如线程函数的成员,能保证它的生命期足够长,直到线程结束,这时使用它的指针不需要用智能指针管理

主要是这个类不影响成员的生命周期,并且能保证成员销毁没有副作用。

9. C++的get函数里面加锁是什么意思?

是锁住复制构造函数不被打断

posted @ 2015-04-09 14:39  枪侠  阅读(473)  评论(0编辑  收藏  举报