操作系统—探究进程与线程的细节

探究进程与线程的部分细节

1.实验基本环境

(1).基本环境

  这次实验我使用了一台运行着Ubuntu-22.04的云服务器完成,它的基本信息如下:
在这里插入图片描述

  平时我用它来跑一些项目的数据库、简单的服务以及我自己的一个wordpress博客站,因为现在mac本身虽然支持pthread,但是行为可能和linux下的有区别,并且目前的mac跑在arm64架构下,可能在调度等等行为上和linux也有区别,因此综合考虑下来,我还是使用一个原生x86-64的Linux完成这次实验。

2.多线程程序

(1).计算问题

  首先完成的是多线程程序,第一次实验采取了下面这一段代码:

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#define MAX_THREADS 10
int a = 0;

void* add()
{
    for (int i = 0; i < 100000; i++)
        a++;
    pthread_exit(NULL);
}

int main()
{
    pthread_t threads[MAX_THREADS];
    int rc;
    for (long i = 0; i < MAX_THREADS; i++) {
        // printf("Main: Creating Thread %ld\n", i);
        rc = pthread_create(&threads[i], NULL, add, NULL);
        if (rc) {
            printf("Main: Failed to create thread %ld\n", i);
            exit(-1);
        }
    }
    for (long i = 0; i < MAX_THREADS; i++) {
        pthread_join(threads[i], NULL);
    }
    printf("a = %d\n", a);
    return 0;
}

  这段代码中创建了一个全局变量a,并且使用10个线程每次对其进行十万次自增操作,最后将结果打印出来;再编译这段代码的时候,需要使用下面的命令:

gcc thread.c -o thread -pthread

  因为需要再编译时附加链接pthread库,因此要加上-pthread参数,多次运行一下生成的可执行文件:
在这里插入图片描述

  一共尝试运行了十次,不仅每一次的运行结果都不相同,并且也和预期的最终结果1000000相距甚远,这应该和自增操作本身不是原子操作有关,因为指令的重排load和store不能保证一次完成以及并发过程中调度的不确定性,a几乎不可能是正确的结果,因此在不加锁的情况下,这个add函数是一个线程不安全的函数

(2).附加自旋锁的计算问题

  因此为了解决这个问题,我决定给add这个函数加上一个自旋锁:

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#define MAX_THREADS 10
int a = 0;
pthread_spinlock_t lock;

void* add()
{
    for (int i = 0; i < 100000; i++) {
        pthread_spin_lock(&lock);
        a++;
        pthread_spin_unlock(&lock);
   }
   pthread_exit(NULL);
}

int main()
{
    pthread_spin_init(&lock, PTHREAD_PROCESS_PRIVATE);
    pthread_t threads[MAX_THREADS];
    int rc;
    for (long i = 0; i < MAX_THREADS; i++) {
        // printf("Main: Creating Thread %ld\n", i);
        rc = pthread_create(&threads[i], NULL, add, NULL);
        if (rc) {
            printf("Main: Failed to create thread %ld\n", i);
            exit(-1);
        }
    }
    for (long i = 0; i < MAX_THREADS; i++) {
        pthread_join(threads[i], NULL);
    }
    printf("a = %d\n", a);
    pthread_spin_destroy(&lock);
    return 0;
}                      

  在运行这段代码前,我首先用time计算了一下不加自旋锁情况下的计算时间:
在这里插入图片描述

  看起来运行的时间大概都在4~5ms左右,接下来我们尝试多次运行加上自旋锁的代码:
在这里插入图片描述

  这次看起来,这个程序的运算结果正确了,但是运行的时候可以感觉到明显变慢,之后调用time查看时间也能发现,这次的代码运行平均需要25~30ms,这也是可以理解的,因为10个线程做的事情只有对a这个变量进行自增,如果加上自旋锁再重排就可能出现频繁的访问冲突,因此这个程序的效率就会明显降低。

(3).IO问题

  我们还可以做另一个简单一点的实验:

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#define MAX_THREADS 10

void* hello(void* t)
{
    printf("Hello, I'm thread %ld\n", *(long*)t);
    pthread_exit(NULL);
}

int main()
{
    pthread_t threads[MAX_THREADS];
    int rc;
    for (long i = 0; i < MAX_THREADS; i++) {
        // printf("Main: Creating Thread %ld\n", i);
        rc = pthread_create(&threads[i], NULL, hello, &i);
        if (rc) {
            printf("Main: Failed to create thread %ld\n", i);
            exit(-1);
        }
    }
    for (long i = 0; i < MAX_THREADS; i++) {
        pthread_join(threads[i], NULL);
    }
    return 0;
}   

  有趣的结果!我们会发现打印出来的线程编号和实际上并不相符,打印10次应该是正确的,但是thread后的标号应该0~9各出现一次,为什么会出现10呢?
在这里插入图片描述

  其实10的出现很好理解,因为这个变量i在退出循环的那一次,会被加到10,此时这些线程读取到的信息应该都是10;而有好几个线程的值相同也很好理解,因为创建线程后,这些线程从地址读取数据的顺序并不能确定,有可能在某一个时隙中,这些线程读取到的都是10这一个值,因此打印出来的都是10。
但这也不代表小数就不能出现在大数后面,因为打印和读取值是两个操作,这两个操作中间可能还会有间隔,例如后面一次尝试中的9在10后,有可能有4个线程先读取到了9,然后有5个线程读到了10,然后有3个线程率先打印出了结果,然后4个9的线程打印结果,最后2个10的线程打印出结果。

(4).改进访问冲突的IO问题

  为此我又优化了一下代码:

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#define MAX_THREADS 10

void* hello(void* t)
{
    printf("Hello, I'm thread %ld\n", *(pthread_t*)t);
    pthread_exit(NULL);
}

int main()
{
    pthread_t threads[MAX_THREADS];
    int rc;
    for (long i = 0; i < MAX_THREADS; i++) {
        // printf("Main: Creating Thread %ld\n", i);
        rc = pthread_create(&threads[i], NULL, hello, &threads[i]);
        if (rc) {
            printf("Main: Failed to create thread %ld\n", i);
            exit(-1);
        }
    }
    for (long i = 0; i < MAX_THREADS; i++) {
        pthread_join(threads[i], NULL);
    }
    return 0;
}

  这次至少能保证它们打印的内容肯定都不相同了:
在这里插入图片描述

  不过打印顺序仍然是不确定的,原因和前面分析的一样。不过在这两个使用printf输出的例子中能够体现出来的最重要的一个点是:printf是一个线程安全的函数

3.多进程程序

  多进程程序的创建需要依靠fork()实现,在查阅资料之后发现其实还有一个更加完善的实现,不过fork()比较简单,这次实验就采用fork实现了

(1).简单的计算问题

#include <stdio.h>
#include <unistd.h>
#include <sys/syscall.h>

int a = 0;

void add()
{
    for (int i = 0; i < 1000; i++)
        a++;
}

int main()
{   
    fork();
    for (int i = 0; i < 1000; i++) {
        add();
    }
    printf("a = %d\n", a);
    return 0;
}

  这个计算问题和多线程程序中的差不多,编译运行后的结果如下:
在这里插入图片描述

  它输出了两次,不过值是正确的,这好像还是挺符合认知的,因为fork后会形成一个子进程,子进程从那一行起执行,因此后面打印两次内容很符合实际情况。

(2).复杂的计算问题

  多次运行之后发现这个a的值始终是正确的,应当是因为fork出的子进程也会克隆对应的变量形成自己的栈空间,为了验证这个猜想,只需要在打印的时候同步将a的地址打印出来即可:
在这里插入图片描述

  我在fork()前和最后的打印中分别加上了对于a的地址的打印,打印的结果相当出乎我预料:a的地址从始至终保持一致,难道说克隆出的栈空间,连变量的地址都会保持一致吗?这一次我改变了一下代码的写法,将子进程和父进程的执行分开,让父进程在运算操作结束后直接将a赋值为0,代码如下:

#include <stdio.h>
#include <unistd.h>
#include <sys/syscall.h>

int a = 0;

void add()
{
    for (int i = 0; i < 1000; i++)
        a++;
}

int main()
{
    printf("&a = %p\n", &a);
    int pid = fork();
    for (int i = 0; i < 1000; i++) {
        add();
    }
    if (pid != 0) {
        a = 0;
    }
    printf("a = %d, &a = %p\n", a, &a);
    return 0;
}

  这次的代码会单独对于进程进行判断,对于父进程(pid != 0),直接在计算结束后对a赋值为0,结果如下:
在这里插入图片描述

  我运行了三次,结果都是地址保持一致,但是子进程的值没有受到影响,因此我得出结论:对于fork前创建的变量,在fork后的若干进程中依旧保持原有的内存布局(包括地址)不变,但是不同进程的栈空间独立,这些变量并不共享,因此其中任何一个进程修改相同地址的值都不会改变另一个进程中对应的值
  这一点是结束了,接下来我有一个新的猜想:在fork之后创建的变量,应该地址就不会再保持一致了,所以我又加上了几行代码:

    int b = 10;
    printf("&b = %p\n", &b);

  运行结果如下:
在这里插入图片描述

  事实证明:我又错了,即便是fork之后在不同进程内创建的变量,依旧具有相同的地址,这也合理,对于已经处在相同内存布局下的程序来说,对于相同代码,当前的栈顶位置应该是一样的,因此哪怕是创建几个变量,它们的地址也应该是一样的。

(3).一些小问题

  事实上,我反复运行这个代码,结果都是&a->&b(父)->a(父)->&b(子)->a(子)这个顺序打印的,于是我就想,真的就都是等到父进程结束之后才有机会让子进程打印吗?结果还真让我找到了一个反例:
在这里插入图片描述

  后者先打印了两次&b,在打印了两次a,这说明:实际上我们看到的大部分情况也只是偶然,对于fork出的两个进程,我们也不能完全认为其调度顺序就是固定的,某个进程的运行的确也可能不能在printf后直接拿到CPU
  不过,之前对于运行顺序的假定其实是基于我没有打印出进程号而产生的,因此我决定给后续的打印都加上进程号,看看是不是真的就是按照先前认为的顺序打印的:
在这里插入图片描述

  基本上是符合之前的假设的,只有之前也出现过的反例情况不太一致,在查阅资料后了解到一般来说父进程会先于子进程执行,对于这一点,我测试的所有结果都验证了这一个说法,因为即便如前面一个小节的反例中的两个printf被打断,第一个被打印的也还是父进程中的&b。

总结

  这次我通过fork和pthread的相关函数探究了并发程序的一些细节,例如多线程程序下可能出现的Race Condition以及对应的解决方案(有锁编程),还了解了多进程程序下内存布局的一些细节,这里比较确定的是,fork创造的多个进程的确会具有完全一致的内存布局(当然只是程序内的虚拟内存一致,映射到的物理内存还是不一致的)

参考资料

  • 28
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值