由于多线程部分内容过多,所以分为两篇来写,下篇传送门:【万字详解Linux系列】多线程(下)。
在一个程序里的一个执行路线就叫做线程(thread)。更准确的定义是:线程是“一个进程内部的控制序列”。
一切进程至少都有一个执行线程。线程在进程内部运行,本质是在进程地址空间内运行,也就是说,进程和线程共享进程地址空间。
运行如下代码,之前讲到用fork创建子进程时,由于写时拷贝,子进程对数据的修改不会影响父进程,但这里vfork使子进程和父进程共享进程地址空间,所以它们看到的是同一片内容,互相修改也是可见的。
#include
#include
#include
int global = 10;
int main()
{
pid_t id = vfork();
if (id == 0)
{
//child
global = 20;//子进程修改全局变量
return 1;
}
//father
printf("global : %d\n", global);//父进程可见
return 0;
}
结构如下,显然子进程的修改对于父进程是可见的。
Linux系统下在CPU眼中,看到的PCB都要比传统的进程更加轻量化。
透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流。
基于轻量级进程的系统调用,Linux在用户层模拟实现了一套线程的接口,包含在pthread下。
请认真区分线程与进程之间的区别与联系,后面很多地方都要注意这两者之间的关系。
进程是资源分配的基本单位,而线程是调度的基本单位。
线程虽然共享进程数据,但也拥有自己的一部分数据:线程ID、一组寄存器、栈、errno、信号屏蔽字、调度优先级。
进程与线程的关系如下图:
进程的多个线程共享同一地址空间,因此Text Segment、Data Segment都是共享的,如果定义一个函数,在各线程中都可以调用;如果定义一个全局变量,在各线程中都可以访问到。
除此之外,各线程还共享以下进程资源和环境:文件描述符表、每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)、当前工作目录、用户id和组id。
参数
返回值:成功返回0;失败返回错误码。
下面的代码通过函数创建线程,并让创建的线程每秒打印一次"thread!"及其pid、ppid,而主函数中每两秒打印一次"main thread"及其pid、ppid。
#include
#include
#include
#include
void* routine(void* arg)
{
char* msg = (char*)arg;
//创建的线程每秒打印一次thread!及其pid、ppid
while (1)
{
printf("%s pid : %d ppid : %d\n", msg, getpid(), getppid());
sleep(1);
}
}
int main()
{
pthread_t tid;
pthread_create(&tid, NULL, routine, (void*)"thread!");
while (1)
{
//主线程(main)每两秒打印一次main thread及其pid、ppid
printf("main thread pid : %d ppid : %d\n", getpid(), getppid());
sleep(2);
}
return 0;
}
上面的程序在编译时就有个小细节,如下:
运行结果如下,这两个线程的pid和ppid都相同,所以说它们是同一个进程的两个执行流。
可以通过ps的-L选项查看轻量级进程(这里可以看到不同的线程):
所以操作系统再调度的时候是以LWP为单位的而并非PID,因为这里显然两个线程的PID相同,如果通过PID就无法区分。
查看线程的编号用的函数是pthread_self,直接调用pthread_self(),它的返回值即是该线程的对应编号。
注意该返回值并不等于上面的LWP,因为该返回值是用户层的数据,而LWP是内核层的数据。
代码如下:
#include
#include
#include
#include
void* routine(void* arg)//让该线程什么都不做
{
return NULL;//return代表线程结束
}
int main()
{
pthread_t tid[5];//创建5个线程
int i = 0;
//循环5次创建线程
for (; i < 5; i++)
{
//创建线程
pthread_create(&tid[i], NULL, routine, (void*)"thread!");
printf("%d tid is %lx pid : %d ppid : %d\n", i, tid[i], getpid(), getppid());//按照long的十六进制打印
}
printf("main thread tid : %lx pid : %d ppid : %d\n", pthread_self(), getpid(), getppid());//按照long的十六进制打印
return 0;
}
这里用到的函数是pthread_join。
下面通过代码来演示函数的调用方法:
#include
#include
#include
#include
void* routine(void* arg)
{
return NULL;
}
int main()
{
pthread_t tid[5];
int i = 0;
for (; i < 5; i++)
{
pthread_create(&tid[i], NULL, routine, (void*)"thread!");
printf("%d tid is %lx pid : %d ppid : %d\n", i, tid[i], getpid(), getppid());
}
printf("main thread tid : %lx pid : %d ppid : %d\n", pthread_self(), getpid(), getppid());
for (i = 0; i < 5; i++)
{
//第一个参数是线程的标识,也就是tid[i]的值
pthread_join(tid[i], NULL);//第二个参数先设置为NULL
printf("thread %d[%lx] quit!\n", i, tid[i]);
}
return 0;
}
该参数可以理解为被等待线程返回时的“退出码”(但要注意参数的类型),即告诉主线程执行得如何。
void* routine(void* arg)
{
//返回10(这里没有什么逻辑需求,可以随意设置)
return (void*)10;//void*强转
}
int main()
{
pthread_t tid[5];
int i = 0;
for (; i < 5; i++)
{
pthread_create(&tid[i], NULL, routine, (void*)"thread!");
printf("%d tid is %lx pid : %d ppid : %d\n", i, tid[i], getpid(), getppid());
}
printf("main thread tid : %lx pid : %d ppid : %d\n", pthread_self(), getpid(), getppid());
for (i = 0; i < 5; i++)
{
void* ret = NULL;
pthread_join(tid[i], &ret);
printf("thread %d[%lx] quit! code : %d\n", i, tid[i], (int)ret);//直接将获得的返回值ret强转为int打印
}
return 0;
}
结果如下:
retval可以理解为被等待线程返回时的“退出码”(但要注意参数的类型),即告诉主线程执行得如何。比如有具体逻辑时,可以让线程完成处理或返回1,如果失败返回0,这样主线程就可以知道每一个线程处理的结果如何。
在【万字详解Linux系列】进程控制 时提到过waitpid可以拿到被等待进程的退出码和收到的信号,那么这里的pthread_join为什么不设置一个参数来获取被等待线程收到的信号呢?
原因是做不到,从前文可以看到,一个进程内的所有线程的PID都是相同的,而在【万字详解Linux系列】进程信号 中可以看到发送的信号都是针对进程(PID)的,也就是说一旦某一个线程出现某些问题收到信号,整个进程(包括其中的所有线程)就都挂掉了,主线程根本没机会获取收到的信号。
这里暂时先仅讨论线程正常退出
由上可以看到,线程执行完routine的代码后会通过return结束,或是主线程(main)通过return返回,这时所有的线程都退出。
#include
#include
#include
#include
void* routine(void* arg)
{
pthread_exit((void*)19);//线程退出,"退出码"设置为19
}
int main()
{
pthread_t tid[5];
int i = 0;
for (; i < 5; i++)
{
pthread_create(&tid[i], NULL, routine, (void*)"thread!");
printf("%d tid is %lx pid : %d ppid : %d\n", i, tid[i], getpid(), getppid());
}
printf("main thread tid : %lx pid : %d ppid : %d\n", pthread_self(), getpid(), getppid());
for (i = 0; i < 5; i++)
{
void* ret = NULL;
pthread_join(tid[i], &ret);
printf("thread %d[%lx] quit! code : %d\n", i, tid[i], (int)ret);
}
return 0;
}
注意这里要注意与exit的区别,exit是退出进程,也就是说如果在routine函数中用exit退出,一旦有一个线程运行到此,整个进程(包括所有线程)就都结束了,而上面的pthread_exit仅仅是某一个线程退出而已。
这个函数一般用于一个线程取消(终止)其它线程。
(当然,也可以自己取消自己,只不过如果仅仅是为了这个功能,前面两个方法已经足够了)
下面通过代码在主线程中取消数组下标为0和3的线程:
#include
#include
#include
#include
#include
void* routine(void* arg)
{
printf("tid : %p\n", pthread_self());
}
int main()
{
pthread_t tid[5];
int i = 0;
for (; i < 5; i++)
{
pthread_create(&tid[i], NULL, routine, (void*)"thread!");
printf("%d tid is %p pid : %d ppid : %d\n", i, tid[i], getpid(), getppid());
}
pthread_cancel(tid[0]);
pthread_cancel(tid[3]);
printf("main thread tid : %p pid : %d ppid : %d\n", pthread_self(), getpid(), getppid());
for (i = 0; i < 5; i++)
{
void* ret = NULL;
pthread_join(tid[i], &ret);
printf("thread %d[%p] quit! code : %d\n", i, tid[i], (int)ret);
}
return 0;
}
上面几乎每个函数都有与pthread_t相关的参数或返回值,那么这个pthread_t的含义到底是什么呢?
事实上pthread_t的含义取决于不同的实现方式。对于Linux使用的NPTL实现而言,pthread_t类型的线程ID,本质就是进程地址空间上的一个地址。
从上面几段程序的运行结果(我在代码中特意将其转化为十六进制或地址进行打印)也可以看到,pthread_t本质就是一个地址。
这些概念大多数在【万字详解Linux系列】进程间通信(IPC)时提到过了,这里仅详细解释一下原子性。在进程间通信或者线程间互相访问时,通常都会涉及到临界资源的问题,为了防止出现该问题采取了许多措施来保证原子性。原子性即是在一个进程(或线程)看来,一块临界资源要么未被另一个进程(或线程)操作,要么已经被另一个进程(或线程)操作完毕,而不会有其它的情况。
下面简单模拟一个“抢票”的程序,逻辑比较简单,主要是为了引出线程互斥。
#include
#include
#include
#include
#include
#define NUM 1000
int tickets = NUM;//定义共1000张票
void* GetTicket(void* arg)
{
int index = (int)arg;//第index个线程
while (1)//一直抢票直到没有剩下票
{
if (tickets > 0)//剩余票数大于0就抢
{
usleep(100);//等待100微秒
printf("thread[%d]正在抢票...剩余%d张票\n", index, tickets--);//tickets先打印再--
}
else//tickets<=0
{
break;
}
}
printf("thread[%d] quit\n", index);//线程退出
}
int main()
{
pthread_t thd[5];//创建5个线程
int i = 0;
for (; i < 5; i++)
{
//创建线程
pthread_create(&thd[i], NULL, GetTicket, (void*)i);
}
//等待每个线程
for (i = 0; i < 5; i++)
{
pthread_join(thd[i], NULL);
}
return 0;
}
运行结果如下,显然出现了问题,最后票剩下了-3张,这显然是有问题的,究其原因是因为没有保证进程互斥。
原因主要有如下三点:
要解决以上问题,需要做到三点:
而上面这些操作本质就是需要一把锁,Linux中提供的锁叫做互斥量。
加锁、解锁需要用到如下的函数:
了解了与锁相关的函数后,就可以针对上面有问题的代码进行修改:
#include
#include
#include
#include
#include
#define NUM 1000
int tickets = NUM;//定义共1000张票
pthread_mutex_t lock;//创建一个锁
void* GetTicket(void* arg)
{
int index = (int)arg;//第index个线程
while (1)//一直抢票直到没有剩下票
{
pthread_mutex_lock(&lock);//每次开始抢票就加锁
if (tickets > 0)//剩余票数大于0就抢
{
usleep(100);//等待100微秒
printf("thread[%d]正在抢票...剩余%d张票\n", index, tickets--);//tickets先打印再--
pthread_mutex_unlock(&lock);//抢完票后解锁
}
else//tickets<=0
{
pthread_mutex_unlock(&lock);//没抢到,解锁
break;
}
}
printf("thread[%d] quit\n", index);//线程退出
}
int main()
{
pthread_mutex_init(&lock, NULL);//初始化锁
pthread_t thd[5];//创建5个线程
int i = 0;
for (; i < 5; i++)
{
//创建线程
pthread_create(&thd[i], NULL, GetTicket, (void*)i);
}
//等待每个线程
for (i = 0; i < 5; i++)
{
pthread_join(thd[i], NULL);
}
pthread_mutex_destroy(&lock);//销毁锁
return 0;
}
结果如下,用锁进行互斥后就不会再出现不同线程同时访问临界资源的问题了。
关于锁还有一下几点需要注意的地方:
死锁是指在一组进程中的各个进程均占有一部分不会释放的资源,但因互相申请被其他进程所占用不会释放的资源而处于的一种永久等待状态。
同一个函数被不同的执行流调用,当前一个执行流还没有执行完,就有其他的执行流再次进入,称之为重入。
一个函数在重入的情况下运行结果不会出现任何不同或者任何问题,则该函数是可重入函数,否则就是不可重入函数。
线程安全:多个线程并发执行同一段代码时,不会出现不同的结果。
线程不安全常见于对全局变量或者静态变量进行操作时没有锁保护的情况下。
如果函数是可重入的,那么它就是线程安全的函数;如果函数是不可重入的,那就不能被多个线程同时使用,有可能会引发线程安全问题。
可重入函数是线程安全函数的一种,线程安全函数不一定是可重入的函数,而可重入函数一定是线程安全的。
如果在函数内部将对临界资源的访问加上锁,则这个函数是线程安全的。