多线程编程是现代软件技术中很重要的一个环节。要弄懂多线程,这就要牵涉到多进程?当然,要了解到多进程,就要涉及到操作系统。不过大家也不要紧张,听我慢慢道来。这其中的环节其实并不复杂。
(1)单CPU下的多线程
在没有出现多核CPU之前,我们的计算资源是唯一的。如果系统中有多个任务要处理的话,那么就需要按照某种规则依次调度这些任务进行处理。什么规则呢?可以是一些简单的调度方法,比如说
1)按照优先级调度
2)按照FIFO调度
3)按照时间片调度等等
当然,除了CPU资源之外,系统中还有一些其他的资源需要共享,比如说内存、文件、端口、socket等。既然前面说到系统中的资源是有限的,那么获取这些资源的最小单元体是什么呢,其实就是进程。
举个例子来说,在linux上面每一个享有资源的个体称为task_struct,实际上和我们说的进程是一样的。我们可以看看task_struct(linux 0.11代码)都包括哪些内容,
- struct task_struct {
- /* these are hardcoded - don't touch */
- long state; /* -1 unrunnable, 0 runnable, >0 stopped */
- long counter;
- long priority;
- long signal;
- struct sigaction sigaction[32];
- long blocked; /* bitmap of masked signals */
- /* various fields */
- int exit_code;
- unsigned long start_code,end_code,end_data,brk,start_stack;
- long pid,father,pgrp,session,leader;
- unsigned short uid,euid,suid;
- unsigned short gid,egid,sgid;
- long alarm;
- long utime,stime,cutime,cstime,start_time;
- unsigned short used_math;
- /* file system info */
- int tty; /* -1 if no tty, so it must be signed */
- unsigned short umask;
- struct m_inode * pwd;
- struct m_inode * root;
- struct m_inode * executable;
- unsigned long close_on_exec;
- struct file * filp[NR_OPEN];
- /* ldt for this task 0 - zero 1 - cs 2 - ds&ss */
- struct desc_struct ldt[3];
- /* tss for this task */
- struct tss_struct tss;
- };
每一个task都有自己的pid,在系统中资源的分配都是按照pid进行处理的。这也就说明,进程确实是资源分配的主体。
这时候,可能有朋友会问了,既然task_struct是资源分配的主体,那为什么又出来thread?为什么系统调度的时候是按照thread调度,而不是按照进程调度呢?原因其实很简单,进程之间的数据沟通非常麻烦,因为我们之所以把这些进程分开,不正是希望它们之间不要相互影响嘛。
假设是两个进程之间数据传输,那么需要如果需要对共享数据进行访问需要哪些步骤呢,
1)创建共享内存
2)访问共享内存->系统调用->读取数据
3)写入共享内存->系统调用->写入数据
要是写个代码,大家可能就更明白了,
- #include <unistd.h>
- #include <stdio.h>
- int value = 10;
- int main(int argc, char* argv[])
- {
- int pid = fork();
- if(!pid){
- Value = 12;
- return 0;
- }
- printf("value = %d\n", value);
- return 1;
- }
上面的代码是一个创建子进程的代码,我们发现打印的value数值还是10。尽管中间创建了子进程,修改了value的数值,但是我们发现打印下来的数值并没有发生改变,这就说明了不同的进程之间内存上是不共享的。
那么,如果修改成thread有什么好处呢?其实最大的好处就是每个thread除了享受单独cpu调度的机会,还能共享每个进程下的所有资源。要是调度的单位是进程,那么每个进程只能干一件事情,但是进程之间是需要相互交互数据的,而进程之间的数据都需要系统调用才能应用,这在无形之中就降低了数据的处理效率。
(2)多核CPU下的多线程
没有出现多核之前,我们的CPU实际上是按照某种规则对线程依次进行调度的。在某一个特定的时刻,CPU执行的还是某一个特定的线程。然而,现在有了多核CPU,一切变得不一样了,因为在某一时刻很有可能确实是n个任务在n个核上运行。我们可以编写一个简单的open mp测试一下,如果还是一个核,运行的时间就应该是一样的。
- #include <omp.h>
- #define MAX_VALUE 10000000
- double _test(int value)
- {
- int index;
- double result;
- result = 0.0;
- for(index = value + 1; index < MAX_VALUE; index +=2 )
- result += 1.0 / index;
- return result;
- }
- void test()
- {
- int index;
- int time1;
- int time2;
- double value1,value2;
- double result[2];
- time1 = 0;
- time2 = 0;
- value1 = 0.0;
- time1 = GetTickCount();
- for(index = 1; index < MAX_VALUE; index ++)
- value1 += 1.0 / index;
- time1 = GetTickCount() - time1;
- value2 = 0.0;
- memset(result , 0, sizeof(double) * 2);
- time2 = GetTickCount();
- #pragma omp parallel for
- for(index = 0; index < 2; index++)
- result[index] = _test(index);
- value2 = result[0] + result[1];
- time2 = GetTickCount() - time2;
- printf("time1 = %d,time2 = %d\n",time1,time2);
- return;
- }
(3)多线程编程
为什么要多线程编程呢?这其中的原因很多,我们可以举例解决
1)有的是为了提高运行的速度,比如多核cpu下的多线程
2)有的是为了提高资源的利用率,比如在网络环境下下载资源时,时延常常很高,我们可以通过不同的thread从不同的地方获取资源,这样可以提高效率
3)有的为了提供更好的服务,比如说是服务器
4)其他需要多线程编程的地方等等
数据同步
多线程创建其实十分简单,在windows系统下面有很多函数可以创建多线程,比如说_beginthread。我们就可以利用它为我们编写一段简单的多线程代码,
- #include <windows.h>
- #include <process.h>
- #include <stdio.h>
- unsigned int value = 0;
- void print(void* argv)
- {
- while(1){
- printf("&value = %x, value = %d\n", &value, value);
- value ++;
- Sleep(1000);
- }
- }
- int main()
- {
- _beginthread( print, 0, NULL );
- _beginthread( print, 0, NULL);
- while(1)
- Sleep(0);
- return 1;
- }
注意,在VC上面编译的时候,需要打开/MD开关。具体操作为,【project】->【setting】->【c/c++】->Category【Code Generation】->【Use run-time library】->【Debug Multithreaded】即可。
通过上面的示例,我们看到作为共享变量的value事实上是可以被所有的线程访问的。这就是线程数据同步的最大优势——方便,直接。因为线程之间除了堆栈空间不一样之外,代码段和数据段都是在一个空间里面的。所以,线程想访问公共数据,就可以访问公共数据,没有任何的限制。
当然,事物都有其两面性。这种对公共资源的访问模式也会导致一些问题。什么问题呢?我们看了就知道了。
现在假设有一个池塘,我们雇两个人来喂鱼。两个人不停地对池塘里面的鱼进行喂食。我们规定在一个人喂鱼的时候,另外一个人不需要再喂鱼,否则鱼一次喂两回就要撑死了。为此,我们安装了一个牌子作为警示。如果一个人在喂鱼,他会把牌子设置为FALSE,那么另外一个人看到这个牌子,就不会继续喂鱼了。等到这个人喂完后,他再把牌子继续设置为TRUE。
如果我们需要把这个故事写成代码,那么怎么写呢?朋友们试试看,
- while(1){
- if( flag == true){
- flag = false;
- do_give_fish_food();
- flag = true;
- }
- Sleep(0);
- }
上面的代码看上去没有问题了,但是大家看看代码的汇编代码,看看是不是存在隐患。因为还会出现两个人同时喂食的情况,
- 23: while(1){
- 004010E8 mov eax,1
- 004010ED test eax,eax
- 004010EF je do_action+56h (00401126)
- 24: if( flag == true){
- 004010F1 cmp dword ptr [flag (00433e04)],1
- 004010F8 jne do_action+43h (00401113)
- 25: flag = false;
- 004010FA mov dword ptr [flag (00433e04)],0
- 26: do_give_fish_food();
- 00401104 call @ILT+15(do_give_fish_food) (00401014)
- 27: flag = true;
- 00401109 mov dword ptr [flag (00433e04)],1
- 28: }
- 29:
- 30: Sleep(0);
- 00401113 mov esi,esp
- 00401115 push 0
- 00401117 call dword ptr [__imp__Sleep@4 (004361c4)]
- 0040111D cmp esi,esp
- 0040111F call __chkesp (004011e0)
- 31: }
- 00401124 jmp do_action+18h (004010e8)
- 32: }
我们此时假设有两个线程a和b在不停地进行判断和喂食操作。设置当前flag = true,此时线程a执行到004010F8处时,判断鱼还没有喂食,正准备执行指令004010F8,但是还没有来得及对falg进行设置,此时出现了线程调度。线程b运行到004010F8时,发现当前没有人喂食,所以执行喂食操作。等到b线程喂食结束,运行到00401113的时候,此时又出现了调度。线程a有继续运行,因为之前已经判断了当前还没有喂食,所以线程a继续进行了喂食了操作。所以,可怜的鱼,这一次就连续经历了两次喂食操作,估计有一部分鱼要撑死了。
当然鱼在这里之所以会出现撑死的情况,主要是因为line 24和line 25之间出现了系统调度。所以,我们在编写程序的时候必须有一个牢固的思想意识,如果缺少必须要的手段,程序可以任何时刻任何地点被调度,那此时公共数据的计算就会出现错误。
数据互斥
在多线程存在的环境中,除了堆栈中的临时数据之外,所有的数据都是共享的。如果我们需要线程之间正确地运行,那么务必需要保证公共数据的执行和计算是正确的。简单一点说,就是保证数据在执行的时候必须是互斥的。否则,如果两个或者多个线程在同一时刻对数据进行了操作,那么后果是不可想象的。
也许有的朋友会说,不光数据需要保护,代码也需要保护。提出这个观点的朋友只看到了数据访问互斥的表象。在程序的运行空间里面,什么最重要的呢?代码吗?当然不是。代码只是为了数据的访问存在的。数据才是我们一切工作的出发点和落脚点。
那么,有什么办法可以保证在某一时刻只有一个线程对数据进行操作呢?四个基本方法:
(1)关中断
(2)数学互斥方法
(3)操作系统提供的互斥方法
(4)cpu原子操作
为了让大家可以对这四种方法有详细的认识,我们可以进行详细的介绍。
(1)关中断
要让数据在某一时刻只被一个线程访问,方法之一就是停止线程调度就可以了。那么怎样停止线程调度呢?那么关掉时钟中断就可以了啊。在X86里面的确存在这样的两个指令,
- #include <stdio.h>
- int main()
- {
- __asm{
- cli
- sti
- }
- return 1;
- }
其中cli是关中断,sti是开中断。这段代码没有什么问题,可以编过,当然也可以生成执行文件。但是在执行的时候会出现一个异常告警:Unhandled exception in test.exe: 0xC0000096: Privileged Instruction。告警已经说的很清楚了,这是一个特权指令。只有系统或者内核本身才可以使用这个指令。
不过,大家也可以想象一下。因为平常我们编写的程序都是应用级别的程序,要是每个程序都是用这些代码,那不乱了套了。比如说,你不小心安装一个低质量的软件,说不定什么时候把你的中断关了,这样你的网络就断了,你的输入就没有回应了,你的音乐什么都没有了,这样的环境你受的了吗?应用层的软件是千差万别的,软件的水平也是参差不齐的,所以系统不可能相信任何一个私有软件,它相信的只是它自己。
(2)数学方法
假设有两个线程(a、b)正要对一个共享数据进行访问,那么怎么做到他们之间的互斥的呢?其实我们可以这么做,
- unsigned int flag[2] = {0};
- unsigned int turn = 0;
- void process(unsigned int index)
- {
- flag[index] = 1;
- turn = 1 - index;
- while(flag[1 - index] && (turn == (1 - index)));
- do_something();
- flag[index] = 0;
- }
其实,学过操作系统的朋友都知道,上面的算法其实就是Peterson算法,可惜它只能用于两个线程的数据互斥。当然,这个算法还可以推广到更多线程之间的互斥,那就是bakery算法。但是数学算法有两个缺点:
a)占有空间多,两个线程就要flag占两个单位空间,那么n个线程就要n个flag空间,
b)代码编写复杂,考虑的情况比较复杂
(3)系统提供的互斥算法
系统提供的互斥算法其实是我们平时开发中用的最多的互斥工具。就拿windows来说,关于互斥的工具就有临界区、互斥量、信号量等等。这类算法有一个特点,那就是都是依据系统提高的互斥资源,那么系统又是怎么完成这些功能的呢?其实也不难。
系统加锁过程,
- void Lock(HANDLE hLock)
- {
- __asm {cli};
- while(1){
- if(/* 锁可用*/){
- /* 设定标志,表明当前锁已被占用 */
- __asm {sti};
- return;
- }
- __asm{sti};
- schedule();
- __asm{cli};
- }
- }
系统解锁过程,
- void UnLock(HANDLE hLock)
- {
- __asm {cli};
- /* 设定标志, 当前锁可用 */
- __asm{sti};
- }
上面其实讨论的就是一种最简单的系统锁情况。中间没有涉及到就绪线程的压入和弹出过程,没有涉及到资源个数的问题,所以不是很复杂。朋友们仔细看看,应该都可以明白代码表达的是什么意思。
(4)CPU的原子操作
因为在多线程操作当中,有很大一部分是比较、自增、自减等简单操作。因为需要互斥的代码很少,所以使用互斥量、信号量并不合算。因此,CPU厂商为了开发的方便,把一些常用的指令设计成了原子指令,在windows上面也被称为原子锁,常用的原子操作函数有
- InterLockedAdd
- InterLockedExchange
- InterLockedCompareExchange
- InterLockedIncrement
- InterLockedDecrement
- InterLockedAnd
- InterLockedOr