引言
在处理一些数据集比较繁杂且解题思路比较单一的问题时,可以通过同一个算法计算多次循环操作解决,但当对效率要求比较高的时候,如果电脑的配置比较好,通常倾向于采用并发编程,其中,线程之间由于切换比较复杂,上下文切换时间较长,并且进程间数据共享比较麻烦,这里我们通常倾向于利用多线程的方法来解决问题。这里我们简要介绍一下多进程、线程思想的一些基本操作。
实验环境
本次讨论我们采用对C/C++编成比较方便的Ubuntu20.04.02(linux内核为5.8.0)操作系统。
利用fork函数创建进程
在UNIX系统中给出的创建新进程的API为fork函数,这个函数可以从当前进程中创建一个几乎一模一样的进程。
每个进程都启动一个从代码的同一位置开始执行的线程。这两个进程中的线程继续执行,就像是两个用户同时启动了该应用程序的两个副本。
fork系统调用用于创建一个新进程,称为子进程,它与进程(称为系统调用fork的进程)同时运行,此进程称为父进程。创建新的子进程后,两个进程将执行fork()系统调用之后的下一条指令。子进程使用相同的pc(程序计数器),相同的CPU寄存器,在父进程中使用的相同打开文件。
它不需要参数并返回一个整数值。下面是fork()返回的不同值。
负值:创建子进程失败。
零:返回到新创建的子进程。
正值:返回父进程或调用者。该值包含新创建的子进程的进程ID 。
创建实例
接下来是一段简单的fork函数的运用C代码:
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main(int argc, char *argv[])
{
pid_t cpid, mypid;
int status=0;
pid_t pid = getpid(); /* get current processes PID */
printf("Parent pid: %d\n", pid);
cpid = fork();
if (cpid > 0) { /* Parent Process */
mypid = getpid();
printf("[%d] parent of [%d]\n", mypid, cpid);
printf("Parent exit\n");
} else if (cpid == 0) { /* Child Process */
mypid = getpid();
printf("[%d] child\n", mypid);
printf("Child exit\n");
} else {
perror("Fork failed");
exit(1);
}
exit(0);
}
fork函数会返回两次,在父进程中,返回的是子进程的PID,在子进程中,返回值为0。如果fork失败(一般是由于内存空间等资源不足无法创建新进程),则会返回负值。
./ProcessBasicAPI-fork
Parent pid: 4007
[4007] parent of [4008]
Parent exit
[4008] child
Child exit
上面为在bash中的运行结果,由于操作系统内部的进程调换操作,每次的结果各个输出顺序可能不同。
进程之间的竞争
由于操作系统对进程的调度问题,当进程之间没有信号操作(等待等操作),通常两个进程的运行顺序会变得不可控,每次程序的运行都会出现不同的输出结果。
竞争实例
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
int i;
pid_t mypid;
pid_t cpid = fork();
if (cpid > 0) {
mypid = getpid();
printf("[%d] parent of [%d]\n", mypid, cpid);
for (i=0; i<10; i++) {
printf("[%d] parent: %d\n", mypid, i);
usleep(1000);
}
} else if (cpid == 0) {
mypid = getpid();
printf("[%d] child\n", mypid);
for (i=0; i>-10; i--) {
printf("[%d] child: %d\n", mypid, i);
usleep(1000);
}
}
return 0;
}
在上述的代码中,运行时会产生不同的主进程和子进程之间的切换顺序,有时会主进程和子进程穿插进行:
./ProcessBasicAPI-races
[4434] parent of [4435]
[4434] parent: 0
[4435] child
[4435] child: 0
[4434] parent: 1
[4435] child: -1
[4434] parent: 2
[4435] child: -2
[4434] parent: 3
[4435] child: -3
[4434] parent: 4
[4435] child: -4
[4434] parent: 5
[4435] child: -5
[4435] child: -6
[4434] parent: 6
[4434] parent: 7
[4435] child: -7
[4434] parent: 8
[4435] child: -8
[4434] parent: 9
[4435] child: -9
同时应该注意到的是,进程之间的数据是不共享的,这里主进程和子进程的i值是不同的。
创建线程
线程和进程最大的区别在于,线程是存在于进程中的,一个进程中可能会有多个不同的线程,每个线程都有着独立的栈和堆,每个线程的动态开辟内存和栈内的变量内存是独立的。互相不干扰。
在C语言中,对线程的操作函数被定义在头文件<pthread.h>中。在C中定义了一个pthread_t数据类型,用来存储线程的ID。
创建线程可以用pthread_create函数,在创建线程之前我们要先定义一个pthread_t变量。
pthread_create函数的声明如下:
int pthread_create(pthread_t *tidp,const pthread_attr_t *attr,
void *(*start_rtn)(void*),void *arg);
为了方便查看这里分为了两行,实际上这只是一行的函数原型,这四个函数参数分别是:
第一个参数为指向线程标识符的指针(pthread_t)。
第二个参数用来设置线程属性。
第三个参数是线程运行函数的起始地址(函数指针)。
最后一个参数是运行函数的参数。
等待线程
由于主线程结束后进程就会结束,所以一般要让主线程等待子线程,否则可能会子线程还没有运行完毕就会结束。
等待线程可以用pthread_join函数。
函数pthread_join用来等待一个线程的结束,线程间同步的操作。头文件 : #include <pthread.h>。
int pthread_join(pthread_t thread, void **retval);
描述 :pthread_join()函数,以阻塞的方式等待thread指定的线程结束。当函数返回时,被等待线程的资源被收回。如果线程已经结束,那么该函数会立即返回。并且thread指定的线程必须是joinable的。
参数 :thread: 线程标识符,即线程ID,标识唯一线程。retval: 用户定义的指针,用来存储被等待线程的返回值。
返回值 : 0代表成功。 失败,返回的则是错误号。
注意事项
因为pthread并非Linux系统的默认库,而是POSIX线程库。在Linux中将其作为一个库来使用,因此加上 -lpthread(或-pthread)以显式链接该库。函数在执行错误时的错误信息将作为返回值返回,并不修改系统全局变量errno,当然也无法使用perror()打印错误信息。
线程实例
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <pthread.h>
void* myfunc(void* args) {
pthread_t tid = pthread_self();
printf("[%ld] child. Exit\n", tid);
}
int main(int argc, char *argv[])
{
pthread_t th;
if(pthread_create(&th, NULL, myfunc, NULL)!=0)
{
perror("pthread_create failed");
exit(1);
}
pthread_t mytid = pthread_self();
printf("Parent tid: %ld\n", mytid);
printf("[%ld] parent of [%ld]. Exit\n", mytid, th);
pthread_join(th, NULL);
exit(0);
}
在这里我们创建了子线程,打印了线程ID,之后在主线程里打印自身ID和子线程ID。
在bash中运行结果如下:
./ThreadBasicAPI-create
Parent tid: 140650212271936
[140650212271936] parent of [140650212267776]. Exit
[140650212267776] child. Exit
线程共享数据
线程之间虽然堆栈是不共享,互相独立的,但是线程都在同一个进程下,这就让全局的静态变量有了用武之地。
我们知道,可执行程序包括BSS段、数据段、代码段(也称文本段)。
BSS段通常是指用来存放程序中未初始化的或者初始化为0的全局变量和静态变量的一块内存区域。特点是可读写的,在程序执行之前BSS段会自动清0。
因此,我们如果想让一个问题由多个线程并发处理,就可以把公共的数据存在BSS段中。
共享数据实例
#include <stdio.h>
#include <pthread.h>
#include <time.h>
#include <stdlib.h>
#define MAX_ARRAY_SIZE 5000
int num[MAX_ARRAY_SIZE];
int total = 0;
void* myfunc1(void* args){
if (args != NULL)
{
char* specialstr = args;
printf("Pthread special string: %s\n",specialstr);
}
pthread_t pid = pthread_self();
int i;
int sum = 0;
for (i = 0;i < 2500; ++i)
{
sum += num[i];
}
printf("Child[%ld]: the sum of num[%d] to num[%d] is: %d\n",pid,0,2499,sum);
total+=sum;
}
void* myfunc2(void* args){
if (args != NULL)
{
char* specialstr = args;
printf("Pthread special string: %s\n",specialstr);
}
pthread_t pid = pthread_self();
int i;
int sum = 0;
for (i = 2500;i < 5000; ++i)
{
sum += num[i];
}
printf("Child[%ld]: the sum of num[%d] to num[%d] is: %d\n",pid,2500,4999,sum);
total+=sum;
}
int main(int argc, char const *argv[])
{
int i;
srand(time(0));
for (i = 0;i < MAX_ARRAY_SIZE; ++i){
num[i] = rand() % 1000;
}
int sum = 0;
pthread_t th1,th2;
pthread_create(&th1,NULL,myfunc1,"First");
pthread_create(&th2,NULL,myfunc2,"Second");
pthread_join(th1,NULL);
pthread_join(th2,NULL);
for(i = 0; i < 5000; ++i){
sum += num[i];
}
pthread_t pid = pthread_self();
printf("Main[%ld]: the sum of num[%d] to num[%d] is: %d\n",pid,0,4999,sum);
printf("Total is %d\n",total );
return 0;
}
这是一个简单的数列求和的函数,这里我们开辟了一个大小为5000的整形数组,我们分别创立了两个线程计算前2500个和以及后5000个和,在这里方便展示,主线程还是求和了一次,实际上可以把两个线程的求和结果都用一个存储在BSS中的静态变量加和记录,就可以两个线程并行处理问题了。
总结
本文讨论了一些很简单的并发编程的方法,读者也可以自己尝试用并发编程,编写一个用户可以自己指定创建多少个线程并行解决问题的程序。