现在的位置: 首页 > 自动控制 > 工业·编程 > 正文

多线程编程杂谈

2013-07-08 22:02 工业·编程 ⁄ 共 6544字 ⁄ 字号 暂无评论

多线程编程的问题,对于大多数程序员来说,都是一个绕不开的坎。在编了一些程序后,我也来谈谈自己的感受。我并不想把本文写成一篇教课书式的文章,而期望是一个工程的入门指引,能够覆盖到大部分工程技巧和解决问题的思路,但又不过于深入而难于理解。因此我会从逻辑层次和实现层次两个部分来讲述多线程编程,更深入的讨论我把它们列在了在参考资料中,这可不是一篇文章能够讨论完的。

1. 逻辑层次
        逻辑层次的多线程编程和操作系统和编程语言应该是无关的。在这个层次上,多线程的概念应该是非常干净的。多线程可以被看成人们并行处理事件时的一种策略。事实上,人们在遇到类似问题,提出的解决方法也通常是类似的。

        首先来看一个不是很恰当的比方。假设你面对这么一个情况,
        1. 手上存在2件事或n件事
        2. 2件或n件事情不存在依赖关系,相互独立。
        你会如何完成这两件事呢?

        这个问题很简单,解决的方法自然也很多。有些人喜欢一心一意的做某件事情,在做完一件事后,再做另外一件事情,这是典型的串行化思维。另外一些人喜欢两件事情同时做,做点A,碰到难题了,做点B,调节一下,再回过去做B。还有一些人情商比较高,门路广,兄弟多,事情自己做一点,另外的事情全部交给兄弟去做就可以了。
        计算机是人发明的,在让计算机并行处理数据的思路上,碰到的问题和解决的方式同上面的情况相比并没有太大的区别。抢占式操作系统按时间片来分配线程的时间,对于操作系统而言(其本身也是一个进程),任何进程或线程都是其业务,必须同时进行。这毫无疑问相当于一个人同时做n件事情。使用单线程完成一个多任务的操作,类似于人们串行做n件事情。剩下的,多线程作业则像把事情交给多个人同时完成。

        上面的比方有点非正式。让我们来重新阐述一下重点。抢占式操作系统根据时间片决定进程(或线程)调度方案,操作系统会剥夺耗时长的进程(或线程)的时间片,提供给其它进程(或线程)。通常情况下,CPU负载都是不饱和的,且一个线程只会运行在一个核上。在多核情况下,增加线程会使进程更加充分的利用硬件资源。在单核情况下,在假设操作系统按照线程来决定调度方案的情况下,可以认为一个线程多的进程获得的CPU计算时间要多于只有一个线程的进程。这是多线程的优势吗?某种程度上是的。在OpenMP的框架中,OpenMP会把一个for循环的计算拆成多个小循环分交给不同的线程去完成。在CPU还存在富余能力的情况下,合理的创建多线程可以减少任务的执行时间。

        那当CPU已经满负荷工作时,情况是怎样呢?此时创建多线程的进程仍然要比没有多线程的进程分到更多的时间片,这相当于穷庙里出了个富方丈。虽然此时作为整体而言已经没有意义,系统的瓶颈是CPU的执行能力。

        线程的创建和销毁在实现上是存在开销的,当线程运行的时间同线程创建的时间相比,差不太多的时候,创建线程的意义就不大了。假设线程创建的时间和线程执行任务的时间相同,那么明显的,不创建线程完成任务的效率是最高的。在实现中,当线程创建和销毁的开销成为系统的瓶颈时,解决方案就是线程池。

        在上面的所有讨论中,其讨论的基础都是任务A和任务B不相关联的情况,那如果A任务和B任务之间存在某种联系呢?让我们来看一下任务之间会存在哪些关系。这些关系对应着多线程中锁的原语。
        1. 任务A和任务B都需要同一资源的支持,并且对资源的访问为独享。对应锁mutex。
        2. 任务A的完成会触发任务B,即任务之间存在依赖。对应锁event或者条件事件。
        3. 任务A与任务B需要某类资源支持,由于资源有限,A和B存在竞争关系,但却可以不是严格的独享制。对应锁semaphore。

        在这3种关系中,第3种关系可以看成是第1和第2种的基础。在工程实现上也是,我们可以用sempahore来实现mutex和event。当semaphore中资源的数目为1时,可以认为是mutex关系。当semaphore中资源数目为1,并且任务A保留资源到任务B等待资源时再释放,可以认为是event关系。

2. 工程实现
2.1 API层次的异同
2.1.1 基础API
        不同的操作系统会提供不同的多线程和锁的API函数,这里只简单介绍一下Windows和基于POSIX库的API函数,对于跨平台的软件的编写,我们可以在此API上进行进一步封装,对上提供统一接口。

        Windows下线程和锁
// 线程 
HANDLE WINAPI CreateThread( 
  _In_opt_   LPSECURITY_ATTRIBUTES lpThreadAttributes, 
  _In_       SIZE_T dwStackSize, 
  _In_       LPTHREAD_START_ROUTINE lpStartAddress, 
  _In_opt_   LPVOID lpParameter, 
  _In_       DWORD dwCreationFlags, 
  _Out_opt_  LPDWORD lpThreadId); 
BOOL WINAPI CloseHandle(_In_  HANDLE hObject); 
 
// 等待锁 
WINBASEAPI DWORD WINAPI WaitForSingleObject( 
  __in HANDLE hHandle, 
  __in DWORD dwMilliseconds); 
WINBASEAPI DWORD WINAPI WaitForSingleObject( 
  __in HANDLE hHandle, 
  __in DWORD dwMilliseconds); 
 
// 互斥量 
WINBASEAPI BOOL WINAPI InitializeCriticalSection( 
  __out LPCRITICAL_SECTION lpCriticalSection); 
WINBASEAPI VOID WINAPI EnterCriticalSection( 
  __inout LPCRITICAL_SECTION lpCriticalSection); 
WINBASEAPI VOID WINAPI LeaveCriticalSection( 
  __inout LPCRITICAL_SECTION lpCriticalSection); 
WINBASEAPI VOID WINAPI DeleteCriticalSection( 
  __inout LPCRITICAL_SECTION lpCriticalSection); 
 
WINBASEAPI __out HANDLE WINAPI CreateMutexA( 
  __in_opt LPSECURITY_ATTRIBUTES lpMutexAttributes, 
  __in     BOOL bInitialOwner, 
  __in_opt LPCSTR lpName); 
BOOL WINAPI ReleaseMutex( 
  _In_  HANDLE hMutex); 
 
// 事件 
HANDLE WINAPI CreateEvent( 
  _In_opt_  LPSECURITY_ATTRIBUTES lpEventAttributes, 
  _In_      BOOL bManualReset, 
  _In_      BOOL bInitialState, 
  _In_opt_  LPCTSTR lpName); 
BOOL WINAPI ResetEvent( 
  _In_  HANDLE hEvent); 
BOOL WINAPI SetEvent( 
  _In_  HANDLE hEvent); 
 
// 信号量 
WINBASEAPI __out HANDLE WINAPI CreateSemaphore( 
    __in_opt LPSECURITY_ATTRIBUTES lpSemaphoreAttributes, 
    __in     LONG lInitialCount, 
    __in     LONG lMaximumCount, 
    __in_opt LPCSTR lpName); 
BOOL WINAPI ReleaseSemaphore( 
  _In_       HANDLE hSemaphore, 
  _In_       LONG lReleaseCount, 
  _Out_opt_  LPLONG lpPreviousCount); 

        Linux下线程和锁
// 线程 
int pthread_create( 
  pthread_t  *  thread, 
  pthread_attr_t * attr,  
  void * (*start_routine)(void *), 
  void * arg); 
void pthread_exit(void *retval); 
int pthread_join(pthread_t th, void **thread_return); 
int pthread_detach(pthread_t th); 
 
// 互斥量 
int pthread_mutex_lock(pthread_mutex_t *mutex); 
int pthread_mutex_unlock(pthread_mutex_t *mutex); 
int pthread_mutex_trylock(pthread_mutex_t *mutex); 
 
// 条件变量 
int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex); 
int pthread_cond_timedwait(pthread_cond_t *cond, pthread_mutex_t  
*mutex, const struct timespec *abstime); 
 
// 信号灯 
int sem_post(sem_t * sem); 
int sem_wait(sem_t * sem); 
int sem_trywait(sem_t * sem); 

        关于上述函数的使用,网络上有非常多的介绍,就不再一一叙述了。只提及一下相关的概念和需要注意到地方,锁的重入性,windows下核心对象和非核心对象的区别,锁的效率,TLS(Thread Local Storage)。

2.1.2 锁的扩展和效率优化
2.1.2.1 原子操作
        除了上述的基本函数外,不同的开发工具还在API层次提供了一些额外的函数,供我们使用。这些函数基于硬件特性设计,实现原子操作,效率要高很多。

        GCC下:
type __sync_fetch_and_add (type *ptr, type value, ...) 
type __sync_fetch_and_sub (type *ptr, type value, ...) 
type __sync_fetch_and_or (type *ptr, type value, ...) 
type __sync_fetch_and_and (type *ptr, type value, ...) 
type __sync_fetch_and_xor (type *ptr, type value, ...) 
type __sync_fetch_and_nand (type *ptr, type value, ...) 
 
type __sync_add_and_fetch (type *ptr, type value, ...) 
type __sync_sub_and_fetch (type *ptr, type value, ...) 
type __sync_or_and_fetch (type *ptr, type value, ...) 
type __sync_and_and_fetch (type *ptr, type value, ...) 
type __sync_xor_and_fetch (type *ptr, type value, ...) 
type __sync_nand_and_fetch (type *ptr, type value, ...) 
// ... 

        VS下:
LONG__cdecl InterlockedIncrement(LONG volatile* Addend); 
LONG__cdecl InterlockedDecrement(LONG volatile* Addend); 
LONG __cdecl InterlockedExchange(LONG volatile *Target, LONG Value); 
LONG__cdec InterlockedExchangeAdd(LONG volatile* Addend, LONGValue); 
// ... 

        基于CAS(Compare and swap)的思想,我们可以实现一些无锁操作。

2.1.2.2 读写锁
        读写锁与互斥量相似,不过读写锁允许更高的并行性,主要用于有很多读操作和很少写操作的情况。读写锁有三种状态:读模式加锁,写模式加锁,不加锁。读写锁可以通过两个信号量和一个互斥锁来实现。互斥量用来维护读操作的个数,两个信号量,一个用于标志无读用户事件发生,一个用于标志无写事件发生。

        Posix下直接提供了读写锁的API,Windows则要自己封装。下面是linux下的相关函数定义:
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock); 
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock, 
       const pthread_rwlockattr_t *restrict attr); 

2.1.2.3 自旋锁
        自旋锁是专为防止多处理器并发而引入的一种锁,它在Linux的内核中大量应用于中断处理等部分(对于单处理器来说,防止中断处理中的并发可简单采用关闭中断的方式,不需要自旋锁)。自旋锁的基本形式如下:
spin_lock(&mr_lock); 
spin_unlock(&mr_lock); 

2.3 设计层次的优化
        采用多线程设计方案时,提高运行效率是方案的最终目的。提高效率可以从两个地方入手,第一是使用更高效率的API函数,第二则是从逻辑的层次去优化。在这里主要关注逻辑层次优化和设计的一些技巧。
        从大的思路上说,提高效率主要可以从2个方面着手:
        1. 减少锁对数据的持有时间
        2. 减小锁的请求频率

        减少锁对数据的持有时间通常方法是把消耗CPU时间的操作移到锁外,或者用消耗少的操作代理消耗多的操作或者改用其他的锁机制。
        减小锁的请求频率,通常的方法是分拆锁和分离锁。对于相互独立的状态变量,必须使用独立的锁进行保护。在拆分一个大锁的时候,要注意拆分时的力度,防止死锁和设计变得过于复杂。

        有一些工程技巧可以借鉴,下面列了一些:
        一种高效无锁内存队列的实现  -- 无锁的应用
        最快线程间数据交换算法,有效避免锁竞争 -- TwoQueues -- 如何分解和优化锁/读写锁优化/用copy操作缩小锁时间
        设计不使用互斥锁的并发数据结构  -- 如何分解和优化锁
        Yet another implementation of a lock-free circular array queue
       借shared_ptr实现copy-on-write  -- 用copy操作缩小锁时间
       并发编程的 15 条建议(译) -- 设计时的一些原则
       多核编程中的线程随机竞争模式的概率分析  -- 对于热点的分析

参考资料:
        <win32多线程程序设计> -- Jim Beveridge / Robert Wiener 著, 侯捷翻译
        <Windows并发编程指南> -- Duffy,J.  著, 聂雪军翻译
        Posix线程编程指南

给我留言

留言无头像?