进程线程同步 进程通信

来源:互联网 发布:人工智能 基金 编辑:程序博客网 时间:2024/06/05 21:03

 

一、进程/线程间同步机制。

临界区、互斥区、事件、信号量四种方式
临界区(Critical Section)、互斥量(Mutex)、信号量(Semaphore)、事件(Event)的区别
1
、临界区:通过对多线程的串行化来访问公共资源或一段代码,速度快,适合控制数据访问。在任意时刻只允许一个线程对共享资源进行访问,如果有多个线程试图访问公共资源,那么在有一个线程进入后,其他试图访问公共资源的线程将被挂起,并一直等到进入临界区的线程离开,临界区在被释放后,其他线程才可以抢占。
2
、互斥量:采用互斥对象机制。只有拥有互斥对象的线程才有访问公共资源的权限,因为互斥对象只有一个,所以能保证公共资源不会同时被多个线程访问。互斥不仅能实现同一应用程序的公共资源安全共享,还能实现不同应用程序的公共资源安全共享 .互斥量比临界区复杂。因为使用互斥不仅仅能够在同一应用程序不同线程中实现资源的安全共享,而且可以在不同应用程序的线程之间实现对资源的安全共享。
3
、信号量:它允许多个线程在同一时刻访问同一资源,但是需要限制在同一时刻访问此资源的最大线程数目 .信号量对象对线程的同步方式与前面几种方法不同,信号允许多个线程同时使用共享资源,这与操作系统中的PV操作相同。它指出了同时访问共享资源的线程最大数目。它允许多个线程在同一时刻访问同一资源,但是需要限制在同一时刻访问此资源的最大线程数目。

PV操作及信号量的概念都是由荷兰科学家E.W.Dijkstra提出的。信号量S是一个整数,S大于等于零时代表可供并发进程使用的资源实体数,但S小于零时则表示正在等待使用共享资源的进程数。
   P操作申请资源:
  (1S1
  (2)若S1后仍大于等于零,则进程继续执行;
  (3)若S1后小于零,则该进程被阻塞后进入与该信号相对应的队列中,然后转入进程调度。
  
  V操作 释放资源:
  (1S1
  (2)若相加结果大于零,则进程继续执行;
  (3)若相加结果小于等于零,则从该信号的等待队列中唤醒一个等待进程,然后再返回原进程继续执行或转入进程调度。
4
、事 件: 通过通知操作的方式来保持线程的同步,还可以方便实现对多个线程的优先级比较的操作 .

总结:
  1.互斥量与临界区的作用非常相似,但互斥量是可以命名的,也就是说它可以跨越进程使用。所以创建互斥量需要的资源更多,所以如果只为了在进程内部是用的话使用临界区会带来速度上的优势并能够减少资源占用量。因为互斥量是跨进程的互斥量一旦被创建,就可以通过名字打开它。
  2.互斥量(Mutex),信号灯(Semaphore),事件(Event)都可以被跨越进程使用来进行同步数据操作,而其他的对象与数据同步操作无关,但对于进程和线程来讲,如果进程和线程在运行状态则为无信号状态,在退出后为有信号状态。所以可以使用WaitForSingleObject来等待进程和线程退出。
  3.通过互斥量可以指定资源被独占的方式使用,但如果有下面一种情况通过互斥量就无法处理,比如现在一位用户购买了一份三个并发访问许可的数据库系统,可以根据用户购买的访问许可数量来决定有多少个线程/进程能同时进行数据库操作,这时候如果利用互斥量就没有办法完成这个要求,信号灯对象可以说是一种资源计数器。

___________________________________________________________________________

线程之间通信的两个基本问题是互斥和同步。

  线程同步是指线程之间所具有的一种制约关系,一个线程的执行依赖另一个线程的消息,当它没有得到另一个线程的消息时应等待,直到消息到达时才被唤醒。

  线程互斥是指对于共享的操作系统资源(指的是广义的"资源",而不是Windows.res文件,譬如全局变量就是一种共享资源),在各线程访问时的排它性。当有若干个线程都要使用某一共享资源时,任何时刻最多只允许一个线程去使用,其它要使用该资源的线程必须等待,直到占用资源者释放该资源。

  线程互斥是一种特殊的线程同步。

  实际上,互斥和同步对应着线程间通信发生的两种情况:

  (1)当有多个线程访问共享资源而不使资源被破坏时;

  (2)当一个线程需要将某个任务已经完成的情况通知另外一个或多个线程时。

  在WIN32中,同步机制主要有以下几种:

  (1)事件(Event);

  (2)信号量(semaphore);

  (3)互斥量(mutex);

  (4)临界区(Critical section)

  全局变量

  因为进程中的所有线程均可以访问所有的全局变量,因而全局变量成为Win32多线程通信的最简单方式。例如:

int var; //全局变量
UINT ThreadFunction(LPVOIDpParam)
{
 var = 0;
 while (var < MaxValue)
 {
  //线程处理
  ::InterlockedIncrement(long*) &var);
 }
 return 0;
}
请看下列程序:
int globalFlag = false;
DWORD WINAPI ThreadFunc(LPVOID n)
{
 Sleep(2000);
 globalFlag = true;

 return 0;
}

int main()
{
 HANDLE hThrd;
 DWORD threadId;

 hThrd = CreateThread(NULL, 0, ThreadFunc, NULL, 0, &threadId);
 if (hThrd)
 {
  printf("Thread launched/n");
  CloseHandle(hThrd);
 }

 while (!globalFlag)
 ;
 printf("exit/n");
}


  上述程序中使用全局变量和while循环查询进行线程间同步,实际上,这是一种应该避免的方法,因为:

  (1)当主线程必须使自己与ThreadFunc函数的完成运行实现同步时,它并没有使自己进入睡眠状态。由于主线程没有进入睡眠状态,因此操作系统继续为它调度C P U时间,这就要占用其他线程的宝贵时间周期;

  (2)当主线程的优先级高于执行ThreadFunc函数的线程时,就会发生globalFlag永远不能被赋值为true的情况。因为在这种情况下,系统决不会将任何时间片分配给ThreadFunc线程。

  事件

  事件(Event)WIN32提供的最灵活的线程间同步方式,事件可以处于激发状态(signaledor true)或未激发状态(unsignal or false)。根据状态变迁方式的不同,事件可分为两类:

  (1)手动设置:这种对象只可能用程序手动设置,在需要该事件或者事件发生时,采用SetEventResetEvent来进行设置。

  (2)自动恢复:一旦事件发生并被处理后,自动恢复到没有事件状态,不需要再次设置。

  创建事件的函数原型为:

HANDLE CreateEvent(
 LPSECURITY_ATTRIBUTES lpEventAttributes,
 // SECURITY_ATTRIBUTES结构指针,可为NULL
 BOOL bManualReset,
 // 手动/自动
 // TRUE:在WaitForSingleObject后必须手动调用ResetEvent清除信号
 // FALSE:在WaitForSingleObject后,系统自动清除事件信号
 BOOL bInitialState, //初始状态
 LPCTSTR lpName //事件的名称
);


  使用"事件"机制应注意以下事项:

  (1)如果跨进程访问事件,必须对事件命名,在对事件命名的时候,要注意不要与系统命名空间中的其它全局命名对象冲突;

  (2)事件是否要自动恢复;

  (3)事件的初始状态设置。

  由于event对象属于内核对象,故进程B可以调用OpenEvent函数通过对象的名字获得进程Aevent对象的句柄,然后将这个句柄用于ResetEventSetEventWaitForMultipleObjects等函数中。此法可以实现一个进程的线程控制另一进程中线程的运行,例如:

HANDLE hEvent=OpenEvent(EVENT_ALL_ACCESS,true,"MyEvent");
ResetEvent(hEvent);

临界区

  定义临界区变量

CRITICAL_SECTION gCriticalSection;


  通常情况下,CRITICAL_SECTION结构体应该被定义为全局变量,以便于进程中的所有线程方便地按照变量名来引用该结构体。

  初始化临界区

VOID WINAPI InitializeCriticalSection(
 LPCRITICAL_SECTION lpCriticalSection
 //指向程序员定义的CRITICAL_SECTION变量
);


  该函数用于对pcs所指的CRITICAL_SECTION结构体进行初始化。该函数只是设置了一些成员变量,它的运行一般不会失败,因此它采用了VOID类型的返回值。该函数必须在任何线程调用EnterCriticalSection函数之前被调用,如果一个线程试图进入一个未初始化的CRTICAL_SECTION,那么结果将是很难预计的。

  删除临界区

VOID WINAPI DeleteCriticalSection(
 LPCRITICAL_SECTION lpCriticalSection
 //指向一个不再需要的CRITICAL_SECTION变量
);


  进入临界区

VOID WINAPI EnterCriticalSection(
 LPCRITICAL_SECTION lpCriticalSection
 //指向一个你即将锁定的CRITICAL_SECTION变量
);


  离开临界区

VOID WINAPI LeaveCriticalSection(
 LPCRITICAL_SECTION lpCriticalSection
 //指向一个你即将离开的CRITICAL_SECTION变量
);


  使用临界区编程的一般方法是:

void UpdateData()
{
 EnterCriticalSection(&gCriticalSection);
 ...//do something
 LeaveCriticalSection(&gCriticalSection);
}


  关于临界区的使用,有下列注意点:

  (1)每个共享资源使用一个CRITICAL_SECTION变量;

  (2)不要长时间运行关键代码段,当一个关键代码段长时间运行时,其他线程就会进入等待状态,这会降低应用程序的运行性能;

  (3)如果需要同时访问多个资源,则可能连续调用EnterCriticalSection

  (4Critical Section不是OS核心对象,如果进入临界区的线程""了,将无法释放临界资源。这个缺点在Mutex中得到了弥补。

  互斥

  互斥量的作用是保证每次只能有一个线程获得互斥量而得以继续执行,使用CreateMutex函数创建:

HANDLE CreateMutex(
 LPSECURITY_ATTRIBUTES lpMutexAttributes,
 // 安全属性结构指针,可为NULL
 BOOL bInitialOwner,
 //是否占有该互斥量,TRUE:占有,FALSE:不占有
 LPCTSTR lpName
 //信号量的名称
);


  Mutex是核心对象,可以跨进程访问,下面的代码给出了从另一进程访问命名Mutex的例子:

HANDLE hMutex;
hMutex = OpenMutex(MUTEX_ALL_ACCESS, FALSE, L"mutexName");
if (hMutex){
 

else{
 
}


  相关API

BOOL WINAPI ReleaseMutex(
 HANDLE hMutex
);


  使用互斥编程的一般方法是:

void UpdateResource()
{
 WaitForSingleObject(hMutex,…);
 ...//do something
 ReleaseMutex(hMutex);
}


  互斥(mutex)内核对象能够确保线程拥有对单个资源的互斥访问权。互斥对象的行为特性与临界区相同,但是互斥对象属于内核对象,而临界区则属于用户方式对象,因此这导致mutexCritical Section的如下不同:

  (1) 互斥对象的运行速度比关键代码段要慢;

  (2) 不同进程中的多个线程能够访问单个互斥对象;

  (3) 线程在等待访问资源时可以设定一个超时值。

  

信号量

  信号量是维护0到指定最大值之间的同步对象。信号量状态在其计数大于0时是有信号的,而其计数是0时是无信号的。信号量对象在控制上可以支持有限数量共享资源的访问。

  信号量的特点和用途可用下列几句话定义:

  (1)如果当前资源的数量大于0,则信号量有效;

  (2)如果当前资源数量是0,则信号量无效;

  (3)系统决不允许当前资源的数量为负值;

  (4)当前资源数量决不能大于最大资源数量。

  创建信号量

HANDLE CreateSemaphore (
 PSECURITY_ATTRIBUTE psa,
 LONG lInitialCount, //开始时可供使用的资源数
 LONG lMaximumCount, //最大资源数
PCTSTR pszName);


  释放信号量

  通过调用ReleaseSemaphore函数,线程就能够对信标的当前资源数量进行递增,该函数原型为:

BOOL WINAPI ReleaseSemaphore(
 HANDLE hSemaphore,
 LONG lReleaseCount, //信号量的当前资源数增加lReleaseCount
 LPLONG lpPreviousCount
);


  打开信号量

  和其他核心对象一样,信号量也可以通过名字跨进程访问,打开信号量的API为:

HANDLE OpenSemaphore (
 DWORD fdwAccess,
 BOOL bInherithandle,
 PCTSTR pszName
);


  互锁访问

  当必须以原子操作方式来修改单个值时,互锁访问函数是相当有用的。所谓原子访问,是指线程在访问资源时能够确保所有其他线程都不在同一时间内访问相同的资源。

  请看下列代码:

int globalVar = 0;

DWORD WINAPI ThreadFunc1(LPVOID n)
{
 globalVar++;
 return 0;
}
DWORD WINAPI ThreadFunc2(LPVOID n)
{
 globalVar++;
 return 0;
}


  运行ThreadFunc1ThreadFunc2线程,结果是不可预料的,因为globalVar++并不对应着一条机器指令,我们看看globalVar++的反汇编代码:

00401038 mov eax,[globalVar (0042d3f0)]
0040103D add eax,1
00401040 mov [globalVar (0042d3f0)],eax


  在"mov eax,[globalVar (0042d3f0)]"指令与"add eax,1" 指令以及"add eax,1" 指令与"mov [globalVar (0042d3f0)],eax"指令之间都可能发生线程切换,使得程序的执行后globalVar的结果不能确定。我们可以使用InterlockedExchangeAdd函数解决这个问题:

int globalVar = 0;

DWORD WINAPI ThreadFunc1(LPVOID n)
{
 InterlockedExchangeAdd(&globalVar,1);
 return 0;
}
DWORD WINAPI ThreadFunc2(LPVOID n)
{
 InterlockedExchangeAdd(&globalVar,1);
 return 0;
}


  InterlockedExchangeAdd保证对变量globalVar的访问具有"原子性"。互锁访问的控制速度非常快,调用一个互锁函数的CPU周期通常小于50,不需要进行用户方式与内核方式的切换(该切换通常需要运行1000CPU周期)。

  互锁访问函数的缺点在于其只能对单一变量进行原子访问,如果要访问的资源比较复杂,仍要使用临界区或互斥。

  可等待定时器

  可等待定时器是在某个时间或按规定的间隔时间发出自己的信号通知的内核对象。它们通常用来在某个时间执行某个操作。

  创建可等待定时器

HANDLE CreateWaitableTimer(
 PSECURITY_ATTRISUTES psa,
 BOOL fManualReset,//人工重置或自动重置定时器
PCTSTR pszName);


  设置可等待定时器

  可等待定时器对象在非激活状态下被创建,程序员应调用 SetWaitableTimer函数来界定定时器在何时被激活:

BOOL SetWaitableTimer(
 HANDLE hTimer, //要设置的定时器
 const LARGE_INTEGER *pDueTime, //指明定时器第一次激活的时间
 LONG lPeriod, //指明此后定时器应该间隔多长时间激活一次
 PTIMERAPCROUTINE pfnCompletionRoutine,
 PVOID PvArgToCompletionRoutine,
BOOL fResume);


  取消可等待定时器

BOOl Cancel WaitableTimer(
 HANDLE hTimer //要取消的定时器
);


  打开可等待定时器

  作为一种内核对象,WaitableTimer也可以被其他进程以名字打开:

HANDLE OpenWaitableTimer (
 DWORD fdwAccess,
 BOOL bInherithandle,
 PCTSTR pszName
);


  实例

  下面给出的一个程序可能发生死锁现象:

#include <windows.h>
#include <stdio.h>
CRITICAL_SECTION cs1, cs2;
long WINAPI ThreadFn(long);
main()
{
 long iThreadID;
 InitializeCriticalSection(&cs1);
 InitializeCriticalSection(&cs2);
 CloseHandle(CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)ThreadFn, NULL, 0,&iThreadID));
 while (TRUE)
 {
  EnterCriticalSection(&cs1);
  printf("/n线程1占用临界区1");
  EnterCriticalSection(&cs2);
  printf("/n线程1占用临界区2");

  printf("/n线程1占用两个临界区");

  LeaveCriticalSection(&cs2);
  LeaveCriticalSection(&cs1);

  printf("/n线程1释放两个临界区");
  Sleep(20);
 };
 return (0);
}

long WINAPI ThreadFn(long lParam)
{
 while (TRUE)
 {
  EnterCriticalSection(&cs2);
  printf("/n线程2占用临界区2");
  EnterCriticalSection(&cs1);
  printf("/n线程2占用临界区1");

  printf("/n线程2占用两个临界区");

  LeaveCriticalSection(&cs1);
  LeaveCriticalSection(&cs2);

  printf("/n线程2释放两个临界区");
  Sleep(20);
 };
}


  运行这个程序,在中途一旦发生这样的输出:

  线程1占用临界区1

  线程2占用临界区2

  或

  线程2占用临界区2

  线程1占用临界区1

  或

  线程1占用临界区2

  线程2占用临界区1

  或

  线程2占用临界区1

  线程1占用临界区2

  程序就""掉了,再也运行不下去。因为这样的输出,意味着两个线程相互等待对方释放临界区,也即出现了死锁。

  如果我们将线程2的控制函数改为:

long WINAPI ThreadFn(long lParam)
{
 while (TRUE)
 {
  EnterCriticalSection(&cs1);
  printf("/n线程2占用临界区1");
  EnterCriticalSection(&cs2);
  printf("/n线程2占用临界区2");

  printf("/n线程2占用两个临界区");

  LeaveCriticalSection(&cs1);
  LeaveCriticalSection(&cs2);

  printf("/n线程2释放两个临界区");
  Sleep(20);
 };
}


  再次运行程序,死锁被消除,程序不再挡掉。这是因为我们改变了线程2中获得临界区12的顺序,消除了线程12相互等待资源的可能性。

  由此我们得出结论,在使用线程间的同步机制时,要特别留心死锁的发生。

____________________________________________________________

二、进程间通信方式

线程间通信:由于多线程共享地址空间和数据空间,所以多个线程间的通信是一个线程的数据可以直接提供给其他线程使用,而不必通过操作系统(也就是内核的调度)。

         进程间的通信则不同,它的数据空间的独立性决定了它的通信相对比较复杂,需要通过操作系统。以前进程间的通信只能是单机版的,现在操作系统都继承了基于套 接字(socket)的进程间的通信机制。这样进程间的通信就不局限于单台计算机了,实现了网络通信。

        进程的通信机制主要有:管道、有名管道、消息队列、信号量、共享空间、信号、套接字。

        管道: 它传递数据是单向性的,只能从一方流向另一方,也就是一种半双工的通信方式;只用于有亲缘关系的进程间的通信,亲缘关系也就是父子进程或兄弟进程;没有名 字并且大小受限,传输的是无格式的流,所以两进程通信时必须约定好数据通信的格式。管道它就像一个特殊的文件,但这个文件之存在于内存中,在创建管道时, 系统为管道分配了一个页面作为数据缓冲区,进程对这个数据缓冲区进行读写,以此来完成通信。其中一个进程只能读一个只能写,所以叫半双工通信,为什么一个 只能读一个只能写呢?因为写进程是在缓冲区的末尾写入,读进程是在缓冲区的头部读取,他们各自 的数据结构不同,所以功能不同。

        有名管道: 看见这个名字就能知道个大概了,它于管道的不同的是它有名字了。这就不同与管道只能在具有亲缘关系的进程间通信了。它提供了一个路径名与之关联,有了自己 的传输格式。有名管道和管道的不同之处还有一点是,有名管道是个设备文件,存储在文件系统中,没有亲缘关系的进程也可以访问,但是它要按照先进先出的原则 读取数据。同样也是单双工的。

        消息队列:是存放在内核中的消息链表,每个消息队列由消息队列标识符标识,于管道不同的是,消息队列存放在内核中,只有在内核重启时才能删除一个消息队列,内核重启也就是系统重启,同样消息队列的大小也是受限制的。

        信号量: 也可以说是一个计数器,常用来处理进程或线程同步的问题,特别是对临界资源的访问同步问题。临界资源:为某一时刻只能由一个进程或线程操作的资源,当信号 量的值大于或等于0时,表示可以供并发进程访问的临界资源数,当小于0时,表示正在等待使用临界资源的进程数。更重要的是,信号量的值仅能由PV操作来改 变。

        共享内存: 就是分配一块能被其他进程访问的内存。共享内存可以说是最有用的进程间通信方式,也是最快的IPC形式。首先说下在使用共享内存区前,必须通过系统函数将 其附加到进程的地址空间或说为映射到进程空间。两个不同进程A、B共享内存的意思是,同一块物理内存被映射到 进程A、B各自的进程地址空间。进程A可以即时看到进程B对共享内存中数据的更新,反之亦然。由于多个进程共享同一块内存区域,必然需要某种同步机制,互 斥锁和信号量都可以。采用共享内存通信的一个显而易 见的好处是效率高,因为进程可以直接读写内存,而不需要任何数据的拷贝。对于像管道和消息队列等通信方式,则需要在内核和用户空间进行四次的数据拷贝,而 共享内存则只拷贝两次数据[1]:一次从输入文件到共享内存区,另一次从共享内存区到输出文件。实际上,进程之间在共享内存时,并不总是读写少量数据后就 解除映射,有新的通信时,再重新建立共享内存区域。而是保持共享区域,直到通信完毕为止,这样,数据内容一直保存在共享内存中,并没有写回文件。共享内存 中的内容往往是在解除映射时才写回文件的。因此,采用共享内存的通信方式效率是非常高的。

         信号: 信号是在软件层次上对中断机制的一种模拟,在原理上,一个进程收到一个信号与处理器收到一个中断请求可以说是一样的。信号是异步的,一个进程不必通过任何 操作来等待信号的到达,事实上,进程也不知道信号到底什么时候到达。信号是进程间通信机制中唯一的异步通信机制,可以看作是异步通知,通知接收信号的进程 有哪些事情发生了。信号机制经过POSIX实时扩展后,功能更加强大,除了基本通知功能外,还可以传递附加信息。信号事件的发生有两个来源:硬件来源(比 如我们按下了键盘或者其它硬件故障);软件来源。信号分为可靠信号和不可靠信号,实时信号和非实时信号。进程有三种方式响应信号1.忽略信号2.捕捉信号 3.执行缺省操作。

        套接字:这一块在网络编程那一块讲的 很多,在此就不在说拉。

_____________________________________________________

三、进程/线程同步机制与进程间通信机制比较

很明显2者有类似,但是差别很大

同步主要是临界区、互斥、信号量、事件

进程间通信是管道、内存共享、消息队列、信号量、socket

共通之处是,信号量和消息(事件)

 

 

原创粉丝点击