05.多线程编程介绍

简单例子1:CPU不密集任务例子

如果不使用线程,我们编写下面的测试程序:

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <time.h>
​
​
void myfunc(void *arg){
    sleep(5);
    printf("%s\n", (char *) arg);
    return ;
}
​
int main(int argc, char *argv[]) {                    
    if (argc != 1) {
    fprintf(stderr, "usage: main\n");
    exit(1);
    }
​
    pthread_t p1, p2;
    printf("main: begin\n");
​
    myfunc("1");
    myfunc("2");
​
    printf("main: end\n");
    return 0;
}

得到运行时间:

main: begin
1
2
main: end
​
real    0m10.003s #实际时间
user    0m0.002s  #用户CPU时间
sys     0m0.000s  #系统CPU时间

我们简单分析一下这个程序,实际运行时间是10.003s

而在这10.003s中,只有0.002s是用户使用的CPU时间。

如果我们的目标只是想完成打印1,2这两个字符,而不考虑其打印顺序的话,我们采用多线程的方式,会有更好的效果。

下面是多线程的代码:

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
​
#include "common.h"
#include "common_threads.h"
​
void *mythread(void *arg) {
    sleep(5);
    printf("%s\n", (char *) arg);
    return NULL;
}
​
int main(int argc, char *argv[]) {                    
    if (argc != 1) {
    fprintf(stderr, "usage: main\n");
    exit(1);
    }
​
    pthread_t p1, p2;
    printf("main: begin\n");
    Pthread_create(&p1, NULL, mythread, "1"); 
    Pthread_create(&p2, NULL, mythread, "2");
    // join waits for the threads to finish
    Pthread_join(p1, NULL); 
    Pthread_join(p2, NULL); 
    printf("main: end\n");
    return 0;
}

其运行结果:

main: begin
2
1
main: end
​
real    0m5.003s
user    0m0.003s
sys     0m0.001s

可以看到,等待的时间从原来的10s,变成了5s

值得注意的是,这时先打印1,还是先打印2其实是不确定的。

使用恰当的话,多线程可以让我们的程序减少不必要的等待。

比如上诉例子中,打印2没有必要等到打印1执行完之后,再执行。这两者可以同时进行,当然这并不会减少CPU的使用时间,因为这是完成打印任务的固有时间,但是当一个任务发生阻塞时,另外一个任务可以继续进行,不会发生阻塞。这减少了不必要的阻塞时间。

如果我们通过良好的设计,我们可以多线程编程提升整体性能,使得我们的程序运行更流畅。

简单的例子。比如一个在线PDF转word的网站,每个用户上传PDF,然后进行转换是几乎没有太多关联的任务,我们可以让每个用户的请求转换为创建一个工作线程去并发处理,发挥服务器的最大能力去工作,而不需要让每个用户都等待别的用户使用完了再使用。

简单例子2:CPU密集任务例子

当我们不使用线程时:

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <time.h>
​
​
void myfunc(void *arg){
    sleep(5);
    printf("%s\n", (char *) arg);
    return ;
}
​
​
void my_count(int input){
    int sum = 0 ;
    while(input){
        sum++;
        input--;
    }
​
    printf("sum = %d\n", sum);
​
    return ;
}
​
int main(int argc, char *argv[]) {                    
    if (argc != 1) {
    fprintf(stderr, "usage: main\n");
    exit(1);
    }
​
    pthread_t p1, p2;
    printf("main: begin\n");
​
    my_count(1000000000);
    my_count(1000000000);
​
​
    printf("main: end\n"); 
    return 0;
}

运行结果:

main: begin
sum = 1000000000
sum = 1000000000
main: end
​
real    0m2.663s
user    0m2.659s
sys     0m0.004s

如果我们使用线程:

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
​
#include "common.h"
#include "common_threads.h"
​
void *mythread(int input) {
    int sum = 0 ;
    while(input){
        sum++;
        input--;
    }
​
    printf("sum = %d\n", sum);
​
    return;
}
​
int main(int argc, char *argv[]) {                    
    if (argc != 1) {
    fprintf(stderr, "usage: main\n");
    exit(1);
    }
​
    pthread_t p1, p2;
    printf("main: begin\n");
    Pthread_create(&p1, NULL, mythread, 1000000000); 
    Pthread_create(&p2, NULL, mythread, 1000000000);
    // join waits for the threads to finish
    Pthread_join(p1, NULL); 
    Pthread_join(p2, NULL); 
    printf("main: end\n");
    return 0;
}

运行结果是:

main: begin
sum = 1000000000
sum = 1000000000
main: end
​
real    0m1.353s
user    0m2.698s
sys     0m0.004s

通过比较,我们可以发现,使用多线程时,真实的运行时间显著下降,这是我们预料到的,但用户使用CPU的时间稍微比不使用多线程的程序的时间要长。

这是为什么呢?多线程在跑两个CPU密集型的任务时,为什么会占用更多的CPU时间?这额外的时间是什么造成的?

原因是线程上下文切换带来的开销。

系统是给每个线程分CPU资源时,是按时间片分的,比如规定每个时间片是1ms,当线程1跑了1ms之后,要切换去跑线程2,这就需要保存当前线程1的寄存器线程,保存当前的状态机,然后载入线程2的上次保留的运行现场,继续线程2的工作。

这个切换,我们称之为上下文切换,这是导致CPU使用时间增加的原因,因为显然如果多了上下文切换的操作,CPU要执行的指令会变多一些。

但实际运行时间,差不多是减少了一半,原因是,我们当前运行的机器有多个CPU或者单个CPU可以跑多个线程。如果我们在一台单核单线程的机器里去跑,会发生什么事呢?

我们使用VM虚拟机,设置CPU数量为1,单个CPU只能跑1个线程。

那么我们的运行结果是这样的:

当我们不适用线程时:

jewinh@ubuntu:~/code/ostep-code/no_thread$ time ./a.out 
main: begin
sum = 1000000000
sum = 1000000000
main: end
​
real    0m4.503s
user    0m4.108s
sys     0m0.016s

当我们使用线程时:

jewinh@ubuntu:~/code/ostep-code/threads-intro$ time ./cpu_full 
main: begin
sum = 1000000000
sum = 1000000000
main: end
​
real    0m4.744s
user    0m4.199s
sys     0m0.020s

是的,我们因为使用了线程,增加了上下文切换的开销,导致运行同样的任务,我们反而需要更长的时间了。

这里我们要注意:有的时候,多线程可能并不能改善性能,反而会是一个累赘

这个例子中,多线程不单只引入了复杂度,而且还让程序执行任务耗费了更多的CPU周期,这真是非常糟糕的设计

问题的本质:误用系统机制

这个程序是我故意设置的,我明明知道在linux中,每个线程运行时,CPU资源是按时间片来分配的。我只要设置一个任务,在很多个时间片中都完成不了(比如循环很多很多次),那么当时间片结束之后,就会进行切换,只要这个切换次数够多,那么上下文切换时间就非常可观了。

而实际上,在通常的情况下,这种疯狂使用CPU的情况还是比较少的,我们更多的时间是耗费在阻塞上,因此多线程编程可以有效的提升CPU使用率,让我们在更有限的资源里,做更多的事情。

为了让一个任务阻塞时,其他任务可以继续跑,我们采用多线程的方法,但这导致了当我们执行CPU密集型任务时,会有额外的上下文切换的开销,这种 trade-off 是值得的。

生产-消费模型:air算法中的多线程设计

在air算法中,有多个线程。

其中一个线程,负责去收集串口,网口发过来的消息,存放在消息队列中。

另外一个线程,负责读取收到的消息,进行逻辑处理。

这是一个典型的生产-消费模型。

如果我们不采用多线程的设计,这将是一场灾难。

想象一下,如果我们采用单线程的方法,通过一个死循环去监听有没有消息,如果有消息,就进行处理,如果没有消息,等5ms再去读消息。

这种方式问题很大。

首先一个问题,如果你读到了消息,然后执行了一个很长的逻辑操作,那么你根本就不知道你消息缓存中的消息是不是已经满了,你可能因此错过一些消息而不自知。

另外一个问题,读消息,用消息,本来是两件可以独立开来的任务,由于你需要顺序逻辑处理,你代码一定是耦合在一起的,这种代码随着时间的发展,会变得无法维护。

如果采用生产-消费者的设计,那么一切都变得合理。

首先,我们不必太担心消息满溢的问题,因为我们用单独的一个线程来收集这些信息,该线程在一定的时间周期里必然有机会获得CPU时间片资源,也就是说系统保证了,该线程在该固定时间内,能去看一下串口和网口到底有没有新的消息。我们只要跟发消息的人沟通好,比如1ms内不能发超过多少条,就能保证消息能正常接收到。

其次,收消息的线程逻辑完全可以复用,消费消息的线程稍作修改就可以变成新的应用,维护难度低。

并发带来的复杂度

《THREE EASY PIECES》原文:

ASIDE: KEY CONCURRENCY TERMS CRITICAL SECTION, RACE CONDITION, INDETERMINATE, MUTUAL EXCLUSION

These four terms are so central to concurrent code that we thought it worth while to call them out explicitly. See some of Dijkstra’s early work [D65,D68] for more details.

• A critical section is a piece of code that accesses a shared resource, usually a variable or data structure.

• A race condition (or data race [NM92]) arises if multiple threads of execution enter the critical section at roughly the same time; both attempt to update the shared data structure, leading to a surprising (and perhaps undesirable) outcome.

• An indeterminate program consists of one or more race conditions; the output of the program varies from run to run, depending on which threads ran when. The outcome is thus not deterministic, something we usually expect from computer systems.

• To avoid these problems, threads should use some kind of mutual exclusion primitives; doing so guarantees that only a single thread ever enters a critical section, thus avoiding races, and resulting in deterministic program outputs.

有4个名词:临界区,比赛条件,不确定性,互相排斥 (以下不是翻译)

  • 临界区,也就是我们常说的锁区,

    当我们要操作多个线程共享的资源时,需要先上锁,操作完之后,再解锁,其他线程如果刚好想获得这把锁,那么该线程就阻塞,让出CPU资源,给其他线程先使用。

    临界区的操作尽可能少一些,因为如果在临界区的操作很长,那么其他线程都进入阻塞,这是会影响性能。

  • 比赛条件,我们常说叫竟态,当两个线程不加锁的一起去操作同一个变量,就出现竟态,这是非常危险的。错误的编写多线程程序,出现竟态的话, 有可能在测试时无法发现,真正上到产品卖出去了,才发现。这是非常可怕的。这常常会出现一些无法复现的情况。这种bug往往是非常难查的。

  • 不确定性,由于有竟态的原因,多线程程序跑出来的结果可能是不太确定的,常常需要我们使用一些锁,确保关键环节的确定性。

  • 互相排斥,也就是我们常说的互斥锁,当我们进入一个临界区,为了避免出现竟态,我们应该上锁,保证程序的确定性。

总结

本文尚未介绍如何使用锁,如何避免竞争条件,只是简单的介绍了多线程的机制。

本文通过2个简单的例子,说明了,多线程并不总是能提升性能,我们要扬长避短,巧妙的使用系统提供的机制。

本文还介绍了,多线程在生产-消费者模型中的应用,多线程编程是如何真实的解决我们的问题的。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值