进程和线程的总结

1、进程与线程的区别

区别

进程

线程

概念

进程是表示资源分配的基本单位。

线程是操作系统可识别的最小执行和调度单位。线程体现的特征是可执行的,是CPU资源的分派单位。

关系

一个进程可以有多个线程,但至少有一个线程。

一个线程线程必定是属于某个进程的。

资源

分配

资源只分配给进程,同一进程的所有线程共享该进程的所有资源。 同一进程中的多个线程共享代码段(代码和常量),数据段(全局变量和静态变量),扩展段(堆存储)。当进程结束时,所有的资源被回收。

每个线程有自己独立的栈段,栈段又叫运行时段,用来存放所有局部变量和临时变量。当进程结束时,线程作为进程的资源也会被终止。

系统
开销

创建或撤消进程时,系统都要为之分配或回收资源,如内存空间、I/o设备等。在进行进程切换时,涉及到整个当前进程CPU环境的保存以及新被调度运行的进程的CPU环境的设置。 线程创建和切换只须保存和设置少量寄存器的内容,并 不涉及存储器管理方面的操作。

 

通信

进程间通信:管道,信号(事件),消息队列,共享内存,内存映射,信号量,套接字。

共享进程资源可直接访问。

同步

进程同步实际上是指不同进程中的线程同步。

注:某些同步方式不能跨进程,如临界区。

互斥,信号量,事件,条件变量,

临界区,读写锁等。

2、线程同步方式

     1)临界区(Critical Section)

通过对多线程的串行化来访问公共资源或一段代码,速度快,适合控制数据访问。在任意时刻只允许一个线程对共享资源进行访问。如果有多个线程试图同时访问临界区,那么在有一个线程进入后其他所有试图访问此临界区的线程将被挂起,并一直持续到进入临界区的线程离开。临界区在被释放后,其他线程可以继续抢占,并以此达到用原子方式操作共享资源的目的。
临界区包含两个操作原语:
EnterCriticalSection() 进入临界区
LeaveCriticalSection() 离开临界区
EnterCriticalSection()语句执行后代码将进入临界区以后无论发生什么,必须确保与之匹配的LeaveCriticalSection()都能够被执行到。否则临界区保护的共享资源将永远不会被释放。虽然临界区同步速度很快,但却只能用来同步本进程内的线程,而不可用来同步多个进程中的线程。

     2)互斥量(Mutex)

互斥量跟临界区很相似,只有拥有互斥对象的线程才具有访问资源的权限,由于互斥对象只有一个,因此就决定了任何情况下此共享资源都不会同时被多个线程所访问。当前占据资源的线程在任务处理完后应将拥有的互斥对象交出,以便其他线程在获得后得以访问资源。而且可以在不同应用程序的线程之间实现对资源的安全共享。

互斥量包含的几个操作原语:
CreateMutex() 创建一个互斥量
OpenMutex() 打开一个互斥量
ReleaseMutex() 释放互斥量
WaitForMultipleObjects() 等待互斥量对象

互斥量使用方式推荐:  

  􀁺用RAII 手法封装mutex 的创建、销毁、加锁、解锁这四个操作。
  􀁺 只用非递归的mutex(即不可重入的mutex)。
  􀁺 不手工调用lock() 和unlock() 函数,一切交给栈上的Guard 对象的构造和析构函数负责,Guard 对象的生命期正好等于临界区。这样我们保证在同一个函数里加锁和解锁,避免在foo() 里加锁,然后跑到bar() 里解锁。
  􀁺 在每次构造Guard 对象的时候,思考调用栈上已经持有的锁,防止因加锁顺序不同而导致死锁(deadlock)。由于Guard 对象是栈上对象,看函数调用栈就能分析用锁的情况,非常便利。
  􀁺 尽量不使用跨进程的mutex,进程间通信只用TCP sockets。
  􀁺 加锁解锁在同一个线程,线程a 不能去unlock 线程b 已经锁住的mutex。( RAII自动保证)
  􀁺 别忘了解锁。(RAII 自动保证)
  􀁺 不重复解锁。(RAII 自动保证)
  􀁺 必要的时候可以考虑用PTHREAD_MUTEX_ERRORCHECK 来排错

     3)信号量(Semaphore)

信号允许多个线程同时使用共享资源,这与操作系统中的PV操作相同。它指出了同时访问共享资源的线程最大数目。它允许多个线程在同一时刻访问同一资源,但是需要限制在同一时刻访问此资源的最大线程数目。在用CreateSemaphore()创建信号量时即要同时指出允许的最大资源计数和当前可用资源计数。一般是将当前可用资源计数设置为最大资源计数,每增加一个线程对共享资源的访问,当前可用资源计数就会减1,只要当前可用资源计数是大于0的,就可以发出信号量信号。但是当前可用计数减小到0时则说明当前占用资源的线程数已经达到了所允许的最大数目,不能在允许其他线程的进入,此时的信号量信号将无法发出。线程在处理完共享资源后,应在离开的同时通过ReleaseSemaphore()函数将当前可用资源计数加1。在任何时候当前可用资源计数决不可能大于最大资源计数。

  PV操作及信号量的概念都是由荷兰科学家E.W.Dijkstra提出的。信号量S是一个整数,S大于等于零时代表可供并发进程使用的资源实体数,但S小于零时则表示正在等待使用共享资源的进程数。
P操作 申请资源:
   (1)S减1;
   (2)若S减1后仍大于等于零,则进程继续执行;
   (3)若S减1后小于零,则该进程被阻塞后进入与该信号相对应的队列中,然后转入进程调度。
  V操作 释放资源:
   (1)S加1;
   (2)若相加结果大于零,则进程继续执行;
   (3)若相加结果小于等于零,则从该信号的等待队列中唤醒一个等待进程,然后再返回原进程继续执行或转入进程调度。
   信号量包含的几个操作原语:
   CreateSemaphore() 创建一个信号量
   OpenSemaphore() 打开一个信号量
   ReleaseSemaphore() 释放信号量
   WaitForSingleObject() 等待信号量

     4)事件(Event)

事件对象也可以通过通知操作的方式来保持线程的同步。并且可以实现不同进程中的线程同步操作。事件包含的几个操作原语:
   CreateEvent() 创建一个事件
   OpenEvent() 打开一个事件
   SetEvent() 回置事件
   WaitForSingleObject() 等待一个事件
   WaitForMultipleObjects() 等待多个事件
   WaitForMultipleObjects 函数原型:
   WaitForMultipleObjects(
   IN DWORD nCount, // 等待句柄数
   IN CONST HANDLE *lpHandles, //指向句柄数组
   IN BOOL bWaitAll, //是否完全等待标志
   IN DWORD dwMilliseconds //等待时间
   )
参数nCount指定了要等待的内核对象的数目,存放这些内核对象的数组由lpHandles来指向。fWaitAll对指定的这nCount个内核对象的两种等待方式进行了指定,为TRUE时当所有对象都被通知时函数才会返回,为FALSE则只要其中任何一个得到通知就可以返回。dwMilliseconds在这里的作用与在WaitForSingleObject()中的作用是完全一致的。如果等待超时,函数将返回WAIT_TIMEOUT。

  5)条件变量

  条件变量(condition variable) 顾名思义是一个或多个线程等待某个布尔表达式为真,即等待别的线程“唤醒”它。条件变量的学名叫管程(monitor)。Java Object 内置的wait(),notify(), notifyAll() 即是条件变量(它们以容易用错著称)。条件变量只有一种正确使用的方式,对于wait() 端:

  1. 必须与mutex 一起使用,该布尔表达式的读写需受此mutex 保护
  2. 在mutex 已上锁的时候才能调用wait()
  3. 把判断布尔条件和wait() 放到while 循环中
写成代码是:

MutexLock mutex;
Condition cond(mutex);
std::deque<int> queue;
int dequeue()
{
    MutexLockGuard lock(mutex);
    while (queue.empty()) { // 必须用循环;必须在判断之后再wait()
        cond.wait(); // 这一步会原子地unlock mutex 并进入blocking,不会与enqueue 死锁
    }
    assert(!queue.empty());
    int top = queue.front();
    queue.pop_front();
    return top;
}

对于signal/broadcast 端:
  1. 不一定要在mutex 已上锁的情况下调用signal (理论上)
  2. 在signal 之前一般要修改布尔表达式
  3. 修改布尔表达式通常要用mutex 保护(至少用作full memory barrier)
写成代码是:

void enqueue(int x)
{
    MutexLockGuard lock(mutex);
    queue.push_back(x);
    cond.notify();
}

  上面的dequeue/enqueue 实际上实现了一个简单的unbounded BlockingQueue。条件变量是非常底层的同步原语,很少直接使用,一般都是用它来实现高层的同步措施,如BlockingQueue 或CountDownLatch。

附:Condition的简单封装:

class Condition : boost::noncopyable
{
public:
    Condition(MutexLock& mutex) : mutex_(mutex)
    {
        pthread_cond_init(&pcond_, NULL);
    }
    ~Condition()
    {
        pthread_cond_destroy(&pcond_);
    }
    void wait()
    {
        pthread_cond_wait(&pcond_, mutex_.getPthreadMutex());
    }
    void notify()
    {
        pthread_cond_signal(&pcond_);
    }
    void notifyAll()
    {
        pthread_cond_broadcast(&pcond_); 
    }
private:
    MutexLock& mutex_;
    pthread_cond_t pcond_;
};

注意:如果一个class 要包含MutexLock 和Condition,请注意它们的声明顺序和初始化顺序,mutex_ 应先于condition_ 构造,并作为后者的构造参数。如:

class CountDownLatch
{
public:
    CountDownLatch(int count)
        : count_(count),
        mutex_(),
        condition_(mutex_)
    { }
private:
    int count_;
    MutexLock mutex_; // 顺序很重要
    Condition condition_;
};

 

3、linux内核中的进程同步    

1)自旋锁

    自旋锁是专为防止多处理器并发而引入的一种锁,它在内核中大量应用于中断处理等部分(对于单处理器来说,防止中断处理中的并发可简单采用关闭中断的方式,不需要自旋锁)。
    自旋锁最多只能被一个内核任务持有,如果一个内核任务试图请求一个已被争用(已经被持有)的自旋锁,那么这个任务就会一直进行忙循环——旋转——等待锁重新可用。要是锁未被争用,请求它的内核任务便能立刻得到它并且继续进行。自旋锁可以在任何时刻防止多于一个的内核任务同时进入临界区,因此这种锁可有效地避免多处理器上并发运行的内核任务竞争共享资源。
    事实上,自旋锁的初衷就是:在短期间内进行轻量级的锁定。一个被争用的自旋锁使得请求它的线程在等待锁重新可用的期间进行自旋(特别浪费处理器时间),所以自旋锁不应该被持有时间过长。如果需要长时间锁定的话, 最好使用信号量。
    自旋锁的基本形式如下:
    spin_lock(&mr_lock);
    //临界区
    spin_unlock(&mr_lock);
   因为自旋锁在同一时刻只能被最多一个内核任务持有,所以一个时刻只有一个线程允许存在于临界区中。这点很好地满足了对称多处理机器需要的锁定服务。在单处理器上,自旋锁仅仅当作一个设置内核抢占的开关。如果内核抢占也不存在,那么自旋锁会在编译时被完全剔除出内核。
    简单的说,自旋锁在内核中主要用来防止多处理器中并发访问临界区,防止内核抢占造成的竞争。另外自旋锁不允许任务睡眠(持有自旋锁的任务睡眠会造成自死锁——因为睡眠有可能造成持有锁的内核任务被重新调度,而再次申请自己已持有的锁),它能够在中断上下文中使用。
   死锁:假设有一个或多个内核任务和一个或多个资源,每个内核都在等待其中的一个资源,但所有的资源都已经被占用了。这便会发生所有内核任务都在相互等待,但它们永远不会释放已经占有的资源,于是任何内核任务都无法获得所需要的资源,无法继续运行,这便意味着死锁发生了。自死琐是说自己占有了某个资源,然后自己又申请自己已占有的资源,显然不可能再获得该资源,因此就自缚手脚了。

2)信号量

   Linux中的信号量是一种睡眠锁。如果有一个任务试图获得一个已被持有的信号量时,信号量会将其推入等待队列,然后让其睡眠。这时处理器获得自由去执行其它代码。当持有信号量的进程将信号量释放后,在等待队列中的一个任务将被唤醒,从而便可以获得这个信号量。

    信号量的睡眠特性,使得信号量适用于锁会被长时间持有的情况;只能在进程上下文中使用,因为中断上下文中是不能被调度的;另外当代码持有信号量时,不可以再持有自旋锁。

  信号量基本使用形式为:
  static DECLARE_MUTEX(mr_sem);//声明互斥信号量
  if(down_interruptible(&mr_sem))
      //可被中断的睡眠,当信号来到,睡眠的任务被唤醒 
      //临界区
  up(&mr_sem);

   如果代码需要睡眠——这往往是发生在和用户空间同步时,使用信号量是唯一的选择。由于不受睡眠的限制,使用信号量通常来说更加简单一些。如果需要在自旋锁和信号量中作选择,应该取决于锁被持有的时间长短。理想情况是所有的锁都应该尽可能短的被持有,但是如果锁的持有时间较长的话,使用信号量是更好的选择。另外,信号量不同于自旋锁,它不会关闭内核抢占,所以持有信号量的代码可以被抢占。这意味者信号量不会对影响调度反应时间带来负面影响。

4、各种同步方式的使用  

使用基本原则:

  1)尽量最低限度地共享对象,减少需要同步的场合;

  2)使用高级的并发编程构件,如TaskQueue、Producer-Consumer Queue、CountDownLatch 等等;

  3)使用底层同步方式 时,需注意其使用环境,见下表。建议用非递归(非可重入)的互斥器和条件变量,偶尔用一用读写锁;

  4)不自己编写lock-free 代码,不去凭空猜测“哪种做法性能会更好”,比如spin lock vs. mutex。

同步方式

线程

进程

linux内核

临界区

速度快,适合控制数据访问问。

×

×

互斥

    此3中同步方式可用于同一个进程的线程同步,也可以用于不同进程中的线程同步。信号量与其它2个不同的是可同时运行多个线程。

注:此处说的进程同步实际上是指不同进程中的线程同步。

×

事件

×

信号量

长期加锁
持有锁是需要睡眠、调度
注:与进程线程的信号量不同。

自旋锁

×

×

低开销加锁
短期锁定
中断上下文中加锁
posted @ 2013-01-04 18:52  startcool  阅读(399)  评论(0编辑  收藏  举报