原创 LInux高级编程 - 线程(Threads)

2010-12-29 21:30 1859 5 5 分类: 工程师职场

ALP Chapter 4 线程(Threads)

  • 线程可以简单理解成为进程的下级。一个系统可以有多个进程,一个进程内部可以有多个线程。
  • 回想上一章讲过的新进程的创建。先是fork,相当于拷贝了一个新的进程,然后调用exec,我们便有了两个毫不相关的进程。线程不一样,当创建一个新的线程时,它和原来的线程是完全共享内存的。如果该线程修改了一个全局变量,则其他所有的线程读到的该变量的值都是修改后的。如果该线程调用了exec,很不幸的,它的所有其他线程“兄弟”都会被终结。
4.1 线程的创建
  • 每个进程都有一个进程id,类似的,每个线程也有一个线程id,在C/C++里面,表示线程id的数据类型是pthread_t。
  • 线程函数(thread function)是一个类型为void* (*) (void*)的函数,线程被创建之后,就执行该函数内的代码。当该函数返回时,线程即结束。\
  • 创建线程的函数时pthread_create,这个函数有以下四个参数:
    • 一个指向pthread_t变量的指针,新创建的线程的id将保存在这里
    • 一个指向线程属性(thread attribute)的指针
    • 一个指向线程函数的指针void* (*) (void*)
    • 一个类型为void*的指针,指向传递给线程函数的参数
  • wait用来等待一个进程的结束,类似的作用于线程的函数名字叫做pthread_join。它有两个参数,等待的线程id(pthread_t)以及该线程的返回值(void*)。
  • pthread_self()返回当前的线程id,pthread_equal()可以用来比较两个线程id。这两个函数相当有用,它们通常在调用pthread_join之前被调用,因为一个线程如果pthread_join自己的话很显然是会产生死锁的。(事实上也是如此,pthread_join会返回EDEADLCK的错误)
  • 创建线程属性(Process attribute)的步骤:
    • 创建一个pthread_attr_t类型的变量
    • 调用pthread_attr_init,参数为上一步创建的变量的指针
    • 修改pthread_attr_t,使之包含我们需要的属性
    • 将pthread_attr_t传入pthread_create
    • 完成之后调用pthread_attr_destroy,参数为第一步创建的变量的指针。
  • 对于大部分的程序(所谓的“大部分的”之外的那些就是一些实时程序),我们用的上的线程属性就是它的分离状态(detach state)。
    • 一个线程可以是可加入的(joinable thread)或者是可分离的(detached thread)。可加入的线程在结束后,如果没有别的线程对其使用pthread_join,则它会挂起并等待,直到有线程调用 pthread_join,它的资源才会被释放。可分离的线程结束后,它的资源可被立即回收,别的线程无法通过pthread_join来保证和它的同步,或者获得它的返回值。
    • 我们可以通过函数pthread_attr_setdetachstate来设置一个线程的分离状态。
    • 即使一个线程创建的时候是可加入的,我们也可以调用pthread_detach来把它设为可分离的。设为可分离的线程不能再重新设为可加入的。
4.2 线程的扼杀(Thread Cancellation)
  • 一个线程结束自己的方法有两种:从线程函数返回,或者调用pthread_exit,可惜这两种都不是我们这里要讨论的对象。除此之外,线程还可以要求另一个线程中止,称为扼杀(canceling)一个线程(这就是为什么我不把cancellation翻译成取消的原因)。
  • 扼杀一个线程只要调用pthread_cancel即可。如果那个线程是可加入的,你可以先加入之,杀掉,然后释放它的资源(这里的资源指的是线程属性)
  • 随便杀人是不对的,随便杀线程也是不对的。如果一个线程自己申请了一些资源,然后莫名其妙被杀了,那些资源就不能释放了。我们不能阻止别人的杀人行为,但可以增强自己的防御能力,线程也可以增强自己的防御能力,它可以把自己声明为以下三种状态之一:
    • 异步可杀的(asynchronously cancelable):线程可以在任何时间被杀。
    • 同步可杀的(synchronously cancelable):线程可以被杀,但它不会马上咽气,它会坚持继续做自己的事情(例如电视中的说一大堆无用而煽情的对白),然后在到达某一个特定的位置之后(比如在说到凶手名字之前那一霎那),再死掉。我们称这些特定的位置为扼杀点(cancellation points)。
    • 无敌的(uncancelable):免疫一切暗杀魔法,反正就是杀不掉。
    • 线程创建后的默认状态是同步可杀的。
  • 我们可以使用pthread_setcanceltype来将线程的扼杀状态设为上面三种的一种。对于扼杀点的创建,函数 pthread_testcancel可以创建,这个函数除了创建扼杀点之外不做任何事情。另外,一些其他函数的内部实现可能会创建扼杀点,所以调用这些函数也会相当于创建了一个扼杀点。可以参考pthread_cancel的man page来查看这些函数的列表。
  • 线程保护自己的更为强大的一种手段是pthread_setcancelstate函数,该函数可以禁止/激活扼杀模式。在禁止扼杀模式的情况下,我们可以放心的写一些critical section的代码。需要注意的是,在离开critical section后,应立即恢复到原来的扼杀模式。
4.3 线程副本数据(Thread-Specific Data)
  • 我们知道,对于全局变量,所有线程是共享的。对于局部变量,各个线程是独享的。介于这两者之间的是线程副本变量(thread-specific variable),每个线程都能够访问这些变量,不同的是每个线程都有各自唯一的一份拷贝。
  • 线程副本变量的读取/修改只能通过特定的函数接口来实现
    • 每个变量都必须有一个对应的key,key的类型是pthread_key_t,可以通过pthread_key_create来创建。 pthread_key_create的第一个参数就是一个pthread_key_t的指针,第二个参数是一个清理函数。该函数会在线程结束的时候被调用(即使该线程是被扼杀的)。
    • 有了key之后就可以通过pthread_setspecific和pthread_getspecific来set/get线程副本变量了。
  • 线程副本数据的清理函数非常有用,用户甚至会专门把一些变量声明为副本数据来使得他们的清理函数可以自动被调用。事实上这样做是多余的,因为我们有一套独立的清理函数机制:
    • 清理函数和前面的一样,是void * (*) (void*)类型的
    • 用pthread_clearnup_push函数来注册清理函数,该函数的第二个参数将会做为清理函数的参数
    • 用pthread_clearnup_pop来注销已注册的清理函数,该函数有一个参数,如果该参数不为0,则在注销清理函数之前,会先调用该清理函数。
  • 在C++中,大家通常习惯于使用析构函数(destructor)来释放资源,因为普遍的观点是析构函数总是会被调用。其实这是错误的观点,当一个线程调用了pthread_exit,C++并不保证所有在栈(Stack)上的变量的析构函数都会被调用!一个聪明的办法是,我们不直接调用pthread_exit,而是抛出一个异常,在该异常的处理函数里面调用pthread_exit

4.4 同步(Synchronization)和关键部分(Critical Sections)

  • 这两个概念出现的地方实在是太多太多了,看得眼皮都起茧了。原理这里就不说了,我们还是集中看看linux是怎么实现这两个概念的吧。
  • Mutex的使用
    • 类型为pthread_mutex_t
    • 初始化的时候调用pthread_mutex_init函数,第一个参数为pthread_mutex_t *,第二个参数为mutex的attribute的指针,类似于thread的初始化,我们可以直接指定NULL。
    • 如果觉得pthread_mutex_init太繁琐,并且你只想使用默认属性的mutex,则把mutex赋值为PTHREAD_MUTEX_INITIALIZER就可以了。
    • 对应的加锁和解锁的函数为pthread_mutex_lock和pthread_mutex_unlock,参数都是pthread_mutex_t *
  • Mutex的类型
    • fast mutex(缺省):如果被同一个线程lock一次以上,就会有deadlock
    • recursive mutex:可以被同一个线程多次lock,但是记住必须调用同样次数的pthread_mutex_unlock
    • error-checking mutex:如果同一个线程由于进行了一次以上的lock而造成死锁的话,那么第二次以及第二此之后的lock会返回EDEADLK的错误值
    • 指定Mutex类型的代码:
      pthread_mutexattr_t attr;
      pthread_mutex_t mutex;
      pthread_mutexattr_init (&attr);
      // recursive mutex则使用PTHREAD_MUTEX_RECURSIVE_NP
      pthread_mutexattr_setkind_np (&attr, PTHREAD_MUTEX_ERRORCHECK_NP);
      pthread_mutex_init (&mutex, &attr);
      pthread_mutexattr_destroy (&attr);
  • block版本的lock函数pthread_mutex_lock无疑是低效的,非block版本的lock函数是pthread_mutex_trylock
  • Semaphore的使用
    • Semaphore的类型是sem_t
    • 初始化的函数是sem_init,第一个参数是sem_t的指针,第二个参数是0(非0代表该semaphore可以被多个进程共享,而目前的GNU不支持这个feature),第三个参数是sem_t的初始值
    • 操作函数分别是sem_wait和sem_post(有些书上也把post叫做signal)
  • 条件变量(Condition Variables)
    • 条件变量也是一种实现同步的机制,两个相关的操作是wait和signal。
    • 条件变量没有计数器,也不占内存。因此wait必须在signal之前,如果signal的时候没有正在wait的thread,则signal丢失。
    • 每个条件变量通常都和一个mutex联合使用
  • 条件变量的使用
    • 条件变量的类型是pthread_cond_t
    • 初始化的函数是pthread_cond_init,第一个参数是pthread_cond_t的指针,第二个参数是条件变量的属性的指针。
    • 操作函数分别是pthread_cond_wait和pthread_cond_signal。这里需要注意的是 pthread_cond_wait需要的第二个参数是一个mutex,并且在调用wait之前该mutex必须是被lock的,wait函数会自动 unlock该mutex,并且在wait成功后自动继续lock该mutex。

4.5 GNU/Linux中线程的实现

  • 这里之所以要特别提出实现问题是因为GNU/Linux对POSIX线程的实现不同于其他Unix平台。
    • 当我们调用pthread_create来创建线程的时候,系统创建的是一个新的进程,并且在该进程内创建我们需要的线程。
    • 这个新创建的进程和用fork创建出来的进程不同,他和原来的进程是共享内存的,而不是像fork出来的那样是做了一份copy的。
    • 其实在第一次调用pthread_create的时候,系统还会创建一个进程,叫做管理进程(manager thread),这牵涉到GNU/Linux的内部实现问题,我们就不展开了。
    • 因此,如果我们有一个程序./pthread-pid,该程序只是调用pthread_create来创建一个新的线程,并且使用无限循环来保证主线程和新的线程都不结束。执行该程序后,我们使用ps来查看,就会发现三个./pthread-pid进程,一个是我们执行的,一个是 pthread_create创建出来的,另一个就是管理进程。
  • Signal的处理
    • Signal发送的单位是进程,恰好GNU/Linux里面线程也是用进程实现的,因此在向一个多线程的程序发送Signal的时候,不会出现无法识别该由哪个线程来负责接受Signal的问题。
    • 在一个多线程程序中,线程之间也可以发送Signal,方法是pthread_kill函数。第一个参数是线程ID,第二个参数是signal number
  • 我们前面提到pthread_create会创建一个和当前进程共享内存的新进程,而fork会创建一个获得当前进程copy的进程。那么pthread_create是怎么做到的?
    • 其实有一个函数是pthread_create和fork的统一形式,该函数叫clone,clone可以指定在新的进程和旧的进程之间有哪些东西是共享的,哪些东西是copy的。
    • 这里提出clone只是为了满足大家的好奇心罢了,一般来说,在我们的程序中应该尽量避免使用这个函数。

4.6 进程Vs. 线程

  • 这是一个永恒的话题,新手们总是弄不清楚在一个需要多任务并发的程序中应该使用进程还是线程。这里列出了他们之间的区别:
    • 一个程序中的所有线程必须执行同一个可执行文件。一个新的子进程可以去执行和父进程不同的可执行文件(fork接exec是通常的做法)。
    • 在线程中,一个老鼠屎坏了一锅汤,如果一个线程出现了问题,其他所有的线程都会收到影响,因为他们共享内存。而进程彼此间独立,不存在这样的问题。
    • 创建一个进程比创建一个线程的代价要大,因为需要额外的内存拷贝的操作。虽然在现在绝大多数操作系统中,都实现了copy-on-write的技术,但进程的创建还是要比线程更费时一些。
    • 如果一个多任务的程序的各个任务是紧耦合的,或者说几乎相同的,线程将是更好的选择。如果各个任务直接没什么紧密的联系,那使用进程会更恰当一些。
    • 在线程之间共享数据是非常容易的,因为他们本身就是内存共享的。而在进程之共享数据要麻烦的多,还要使用一种叫做IPC的机制(我们下章会详细描述),但是进程之间的数据共享对于死锁等同步问题具有更强的抵抗能力。
PARTNER CONTENT

文章评论0条评论)

登录后参与讨论
EE直播间
更多
我要评论
0
5
关闭 站长推荐上一条 /3 下一条