Linux网络编程 - 多线程服务器端的实现(1)

2023-05-16

引言

        本来,线程在 Windows 中的应用比在 Linux 平台中的应用更广泛。但 Web 服务的发展迫使 UNIX 系列的操作系统开始重视线程。由于 Web 服务器端协议本身具有的特点,经常需要同时向多个客户端提供服务。因此,人们逐渐舍弃进程,转而开始利用更高效的线程实现 Web 服务器端。

一  理解线程的概念

1.1 引入线程的背景

前面的博文中我们介绍了多进程服务器端的实现方法,但很多时候在一个应用程序中使用多个进程,则会存在一些明显的缺点。

  • 创建进程的过程会带来很大的系统开销

由于 fork 函数是一个开销很大的系统调用,所以创建子进程时会增加一些基本开销,这是因为启动一个新的进程必须分配给它独立的地址空间,建立众多的数据表来维持它的代码段、堆栈段和数据段。

  • 为了完成进程间的数据交换,需要特殊的 IPC(Inter Process Communication,进程间通信)技术

由于每个进程都要自己独立的地址空间,因此必须使用进程间通信的手段,如管道、消息队列、共享内存等。

  • 进程的上下文切换开销很大。(这是使用进程时的最大开销

进程在内核中的数据结构又称为上下文(Content)。上下文包括3个部分:用户级上下文是进程地址空间的内容;寄存器上下文是进程运行时装入CPU寄存器的内容;系统级上下文是进程在内核中的数据结构。

Linux系统是一个分时操作系统,Linux内核可以同时运行多个进程,并为每一个进程运行分配CPU时间片。当一个进程的CPU时间片结束后,Linux内核会调度另一个进程到CPU上执行,如此往复,这就是进程的上下文切换Context Switching)。

操作系统在对两个进程进行切换时,CPU会收到一个软中断,这时原进程上下文将被保存起来,称之为保护现场,然后CPU执行另一个进程。当原进程再次被调度到CPU上运行时,上下文被还原到相关位置上,称之为还原现场,这就是进程上下文切换的过程,保存上下文的数据空间称为 u 区,是Linux 内核为进程分配的存储空间。

简而言之,进程的上下文切换就是切换不同进程到CPU上运行的过程,如果内存空间不足时,还需要将被替换的进程相关信息移出内存,临时存放到硬盘上(Swap分区),并读入待运行进程的相关信息到内存中。可以看到,这个上下文切换过程是很费时费力的,即使通过优化加快速度,也会存在一定的局限。

        为了在一定程度上克服多进程的上述缺点,人们引入了线程(Thread)。这是为了将进程的各种缺点降至最低限度(不能直接消除)而设计出的一种“轻量级进程”(Light Weight Process,LWP)。线程相比进程具有如下优点:

  • 线程的创建和上下文切换比进程的创建和上下文切换速度更快。
  • 线程间交换数据时无需特殊技术。

1.2 线程与进程的差异

线程是为了解决如下困惑登场的:

嘿!为了得到多条代码执行流而复制整个进程内存空间的负担太重了!

        每个进程的内存空间都由保存变量的 “数据区”、使用malloc等函数动态分配的堆区(Heap)、函数运行时使用的栈区(Stack)构成。每个进程都有这种独立的内存空间,多个进程的内存结构如下图 1 所示。

图1  进程间独立的内存结构

 但如果以获得多个代码执行流为主要目的,则不应该像上图 1 那样完全分离内存结构,而只需分离栈区域。通过这种方式可以获得如下优势。

  • 上下文切换时不需要切换数据区和堆区。
  • 可以利用数据区和堆区交换数据。

实际上这就是线程。线程为了保持多条代码执行流而隔开了栈区域,因此具有如下图 2 所示的内存结构。

图2  线程的内存结构

 如上图 2 所示,多个线程将共享数据区和堆区。为了保持这种结构,线程将在进程内创建并运行。也就是说,进程和线程可以定义如下形式:

  • 进程:在操作系统构成单独执行流的单位。
  • 线程:在进程构成单独执行流的单位。

进程与线程的本质区别:进程是操作系统进行资源分配和调度的基本单位,是程序执行的最小单位;而线程是CPU执行和调度的基本单位。

        如果说进程在操作系统内部生成多个执行流,那么线程就在同一进程内部创建多条执行流。因此,操作系统、进程、线程之间的关系可以通过下图 3 表示。

图3  操作系统、进程、线程之间的关系

 二  Linux 线程的实现

2.1 Linux 线程库

        早期 Linux 系统不支持线程,直到1996年,Xavier Leroy 等人才开发出第一个基本符合 POSIX 标准的线程库 LinuxThreads。但 LinuxThreads 效率低。自内核 2.6 版本开始,Linux才真正提供内核级的线程支持,并有两个组织致力于编写新的线程库:NGPT(Next Generation POSIX Threads) 和 NPTL(Native POSIX Threads Library)。不过前者在 2003 年就放弃了,因此新的线程库就称为 NPTL。NPTL 比 LinuxThreads 效率高,且更符合 POSIX(Portable Operating System Interface)规范,所以它已经成为 glibc 库的一部分。本文所有线程相关的例程使用的线程库都是 NPTL

知识补充》默认Linux 线程库 — NPTL

        Linux内核从 2.6 版本开始,提供了真正的内核线程,默认使用的线程库是 NPTL,该线程库在可用性、稳定性以及 POSIX 兼容性方面都远远优于最初设计的 LinuxThreads 线程库。用户可以使用如下命令来查看当前Linux系统上所使用的线程库。比如本人使用的Linux系统如下:

$ cat /etc/redhat-release
CentOS Linux release 7.9.2009 (Core)
$ getconf GNU_LIBPTHREAD_VERSION
NPTL 2.17

英语缩略词

POSIX(Portable Operating System Interface,可移植操作系统接口) 是为了提高 UNIX 系列操作系统间的移植性而制定的 API 规范。

2.2 用户态线程

        用户态线程是由进程负责调度管理、高度抽象化的、与硬件平台无关的线程机制。其最为显著的标志是,进程在创建多个线程时不需要操作系统内核的参与,也不直接对CPU标志寄存器进行操作。用户态线程的优势在于下面两个方面。

  • 减少多线程的系统开销:进程下的线程进行调度切换时,不需要进行系统调用,也就是不需要内核参与。同一个进程内可创建的线程数没有限制。
  • 用户态线程实现灵活多变:可根据实际需要设计相应的用户态线程机制,对于实时性要求高的程序格外重要。

        虽然用户态线程有快速和灵活的特性,但是也存在一个严重的问题,如果进程中的其中一个线程被阻塞,则进程会进入睡眠状态,该进程内的其他线程也会被阻塞,例如,当一个线程由于磁盘 I/O 而被阻塞时,其他线程同样也不能运行。另外,用户态线程不能发挥多路处理器和多核处理器的性能优势。

2.3 内核态线程

        内核态线程是由 Linux 操作系统根据 CPU 硬件的特点,以硬件底层模式实现的线程机制。内核态线程由内核来管理的,在每一个CPU时间片内,都是由内核来负责调度进程内的线程。由于内核参与了用户态进程的调度,所以就涉及了内核态与用户态上下文切换。通常所说的内核态线程切换速度慢就是由于这个原因导致的。

        使用内核态线程明显一个好处就是当进程内的某个线程被阻塞时,其他线程仍可以利用CPU时间片运行。内核态线程机制将所有线程按照同一调度算法调度,更有利于发挥多路处理器和多核处理器所支持的并发处理特性的优势。

        内核态线程相较于用户态线程,内核态线程的系统开销稍大,并且必须通过系统调用实现,对硬件和 Linux 内核版本的依赖性较高,不利于程序移植。

三  线程的操作

        本节我们将以 pthread 线程为标准讲解 POSIX 线程库的使用方法。pthread 线程对应的函数库为 libpthread。它支持 NPTL 线程模型,以用户态线程机制实现。该函数库的接口被定义在 pthread.h 头文件中。

补充说明》NPTL线程库 与 libpthread 函数库的关系

在NPTL实现中,用户创建的线程和内核中调度实体的关系是 1:1,什么意思呢?就是说当在进程内创建一个线程时,在内核中会创建一个与该线程对应的数据结构实体,称为内核态线程。因此,一个用户空间线程被映射为一个内核空间线程。

libpthread 函数库是使用 NPTL 的方式创建线程的,因此使用该函数库创建的线程,属于内核态线程。

这涉及到线程模型问题,请参考下面的博文链接

Linux线程模型

Linux 线程实现模型

3.1 线程的创建和执行流程

线程具有单独的执行流,因此需要单独定义线程的 main 函数,还需要请求操作系统在单独的执行流中执行该函数,完成该功能的函数如下。

  • pthread_create() — 创建一个新线程。
#include <pthread.h>

int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
                  void *(*start_routine) (void *), void *arg);

参数说明

  • thread:保存新创建线程ID的变量地址值。线程与进程相同,也需要区分不同线程的ID。
  • attr:用于传递线程属性的参数,传递 NULL 时,创建默认线程属性。
  • start_routine:相当于线程main函数的、在单独执行流中执行的函数地址值(函数指针)。
  • arg:通过第三个参数传递调用函数时包含传递参数信息的变量地址值。

返回值】成功时返回0,失败时返回错误编号。

  • pthread_t 数据类型说明

#include <bits/pthreadtypes.h>

typedef unsigned long int pthread_t;

可见,pthread_t 是一个无符号长整型类型。实际上,Linux上几乎所有的资源标识符都是一个整型数,比如 socket、各种 System V IPC 标识符等。

编程实例:使用 pthread_create 函数创建线程的示例。

  • thread1.c
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>

void* thread_main(void *arg);

int main(int argc, char *argv[])
{
    pthread_t tid;          //用来保存线程ID
    int thread_param = 5;
    
    //创建线程
    if(pthread_create(&tid, NULL, thread_main, &thread_param) != 0)
    {
        puts("pthread_create() error!");
        return -1;
    }
    sleep(10);
    puts("End of main()");
    return 0;
}

//线程执行main函数
void* thread_main(void *arg)
{
    int i;
    int cnt = *(int*)arg;
    for(i=0; i<cnt; i++)
    {
        sleep(1);
        puts("running thread");
    }
    return NULL;
}
  • 代码说明
  • 第13行:调用 pthread_create 函数创建一个线程,从 thread_main 函数调用开始,在单独的执行流中运行。同时在调用 thread_main 时向其传递 thread_param 变量的地址值。
  • 第18行:调用 sleep 函数使main函数暂停10秒,这是为了延迟进程的终止时间。main函数中执行第20行的 return 语句后终止进程,同时也将终止进程内创建的线程的运行。因此,为保证线程的正常运行而添加这条语句。
  • 第24、27行:传入 arg 参数的是第13行 pthread_create 函数的第四个参数。
  • 运行结果

$ gcc thread1.c -o thread1 -lpthread
$ ./thread1
running thread
running thread
running thread
running thread
running thread
End of main()

        从上述运行结果中可以看出,线程相关代码在编译时需要添加 -lpthread 选项,作用是把程序与 libpthread 函数库相连,只有这样才能调用在头文件 pthread.h 中声明的函数。上述程序的执行流程如下图 4 所示。

图4  示例thread1.c的执行流程

        上图4的虚线代表执行流程,向下的箭头指的是执行流,横向箭头是函数调用。接下来将 thread1.c 示例的第18行的sleep函数的调用语句改为如下形式:

sleep(2);
  • 运行结果

$ gcc thread1.c -o thread1 -lpthread
$ ./thread1
running thread
running thread
End of main()

从运行结果可以看到,只输出了两次 "running thread" 字符串。这是因为main函数返回后整个进程将被销毁,如下图 5 所示。

图5  终止进程和线程

        正因如此,我们之前在 thread1.c 示例中通过调用 sleep 函数向线程提供了充足的执行时间。但是通过调用 sleep 函数控制线程的执行流程,显然是不切实际的办法。稍有不慎,就会干扰程序的正常执行流,因为它无法准确预测 thread_main 线程函数的运行结束时间,并让 main 函数刚好等待这么长的时间。因此,我们不用 sleep 函数,而是通常利用下面的函数控制线程的执行流。

  • pthread_join() — 等待一个线程的结束。
#include <pthread.h>

int pthread_join(pthread_t thread, void **retval);

参数说明

  • thread:线程ID。指的是被等待终止的线程ID。
  • retval:保存第一个参数标识的线程终止时返回值的指针变量地址值。注意!它是一个二级指针。

返回值】成功时返回0,失败时返回错误编号。

        调用 pthread_join 函数的进程(或线程)将进入等待(阻塞)状态,直到第一个参数标识的线程终止运行才返回。而且可以通过第二个参数得到线程结束时的返回值信息。

编程实例:pthread_join 函数的使用示例。

  • thread2.c
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>

void* thread_main(void *arg);

int main(int argc, char *argv[])
{
    pthread_t tid;          //用来保存线程ID
    int thread_param = 5;
    void *thr_ret;          //声明一个空指针变量
    
    //创建线程
    if(pthread_create(&tid, NULL, thread_main, &thread_param) != 0)
    {
        puts("pthread_create() error!");
        return -1;
    }
    
    //等待线程终止运行
    if(pthread_join(tid, &thr_ret) != 0)
    {
        puts("pthread_join() error!");
        return -1;
    }
    printf("Thread return message: %s\n", (char*)thr_ret);
    free(thr_ret);  //释放动态内存
    return 0;
}

//线程执行main函数
void* thread_main(void *arg)
{
    int i;
    int cnt = *(int*)arg;
    char *msg = malloc(sizeof(char) * 50);  //动态内存分配
    strcpy(msg, "Hello, I`m thread~");
    for(i=0; i<cnt; i++)
    {
        sleep(1);
        puts("running thread");
    }
    return (void*)msg;
}
  • 代码说明
  • 第23行:在main函数中,针对第16行创建的线程调用 pthread_join 函数。因此,main 函数将一直等待线程ID为 tid 的线程终止运行。
  • 第13、23、45行:通过这三条语句获取线程的返回值。简言之,第45行返回的值(这是个地址值)将保存到第23行第二个参数 thr_ret。需要注意的是,该返回值是 thread_main 函数内部动态分配的内存空间的地址值,在第29行语句中,调用 free 函数释放掉动态分配的内存。

特别提醒】动态内存空间是在进程地址空间的堆区上开辟的,需要程序员手动释放。

  • 运行结果

$ gcc thread2.c -o thread2 -lpthread
$ ./thread2
running thread
running thread
running thread
running thread
running thread
Thread return message: Hello, I`m thread~

thread2.c 示例的执行流程,如下图 6 所示。

图6  调用pthread_join函数

 3.2 线程的销毁(3种方法)

单个线程可以通过下面3种方式退出,可以在不终止整个进程的情况下,停止线程的控制流。

(1)线程可以直接从启动例程(也就是线程函数)中返回,即执行return语句,返回值是线程的退出码。

(2)线程函数本身调用 pthread_exit()。函数返回线程退出后传出来的 retval 指针。

(3)线程可以被同一进程中的其他线程取消。即其他线程调用 pthread_cancel() 函数。

  • pthread_exit() — 线程主动退出函数。
#include <pthread.h>

void pthread_exit(void *retval);

参数说明

  • retval:用来保存线程退出时的终止状态信息。

函数说明】当线程调用 pthread_exit 函数时,线程主动退出,终止运行。

  • pthread_cancel() — 线程取消函数。
#include <pthread.h>

int pthread_cancel(pthread_t thread);

参数说明

  • thread:需要被取消的线程的线程ID。

返回值】成功时返回0,失败时返回对应的错误编号。

函数说明】线程可以通过调用 pthread_cancel 函数来请求取消同一进程内的其他线程。

        在调用 pthread_cancel 函数取消一个线程后,需要调用相应的函数对线程退出之后的环境进行清理,这些函数被称为线程清理处理程序(Thread Cleanup Handler),线程可以建立多个清理处理程序,对这些函数的标准调用格式说明如下:

#include <pthread.h>

void pthread_cleanup_push(void (*routine)(void *), void *arg);

void pthread_cleanup_pop(int execute);

        pthread_cleanup_push 函数将子程序 routine 连同它的传入参数 arg 一起压入当前线程的 cleanup 处理程序的堆栈;当当前线程调用 pthread_exit 函数或者是通过 pthread_cancel 函数终止执行时,堆栈中的处理程序将按照压栈的相反顺序依次被调用。

        而 pthread_cleanup_pop 函数则是从线程的 cleanup 处理程序堆栈中弹出最上面的一个处理程序并执行它。

        需要注意的是,其他真正对线程执行清理工作的是在 pthread_cleanup_push 函数中作为参数传递进去的 routine 函数,其参数通过 arg 传递进去,其在线程执行如下动作的时候被调用:

  • 调用 pthread_exit 函数的时候。
  • 响应取消线程请求时,即调用 pthread_cancel 函数的时候。
  • 用非零 execute 参数调用 pthread_cleanup_pop 时。

如果传递给 pthread_cleanup_pop 函数的 execute 参数的实参值为 0 时,清理函数将不会被调用,无论在哪种情况下,pthread_cleanup_pop 都将删除 pthread_cleanup_push 函数调用时建立的线程清理处理程序。

  • pthread_detach() — 分离线程。
#include <pthread.h>

int pthread_detach(pthread_t thread);

参数说明

  • thread:需要分离的线程标识符,即线程ID。

返回值】成功时返回0,失败时返回错误编号。

        在Linux系统中,线程一般有分离和非分离两种状态。默认情况下,线程是非分离状态的,父线程维护子线程的某些信息并等待子线程的结束,在没有显式调用 pthread_join 的情形下,子线程结束时,父线程维护的信息可能还没有得到及时释放,如果父线程中大量创建这种非分离状态的子线程(在Linux系统中调用 pthread_create 函数),可能会出现堆栈空间不足的错误,其出错的返回值是 12。而对于分离线程来说,不会有其他的线程等待它的结束,因此线程终止运行后,其所占用的内存空间可以立即得以释放。

        调用 pthread_detach 函数后,不能再针对相应线程调用 pthread_join 函数,因为对分离线程调用pthread_join会产生未定义的行为。这需要格外注意。

【关于线程终止的内容,参考博文链接】

线程终止

3.3 可在临界区内调用的函数

        上文的示例中只创建了一个线程,接下来的示例将开始创建多个线程。当然,无论创建多少个线程,其创建方法没有区别。但关于线程的运行需要考虑:“多个线程同时调用函数时(执行时)可能产生的问题”。这类函数内部存在临界区Critical Section),也就是说,多个线程同时执行这部分代码时,可能引发问题。临界区中至少存在一条这类代码。

知识补充什么是临界区?

        在任意时刻只允许一个线程对共享资源进行访问的区域。这个临界区可能是代码块、或是共享内存。

        例如,多个线程同时访问同一个全局变量,如果都是读取操作,则不会出现问题。 如果一个线程负责改变此变量的值,而其他线程负责同时读取变量的值,则不能保证读取到的数据是经过写线程修改后的。为了确保读线程读取到的是经过修改后的值,就必须在向全局变量写入数据时禁止其他线程对其的任何访问,直至赋值过程结束后再解除对其他线程的访问限制。

        上面示例中,从代码角度讲,全局变量就是共享资源,访问全局变量的语句或语句块就是临界区;从内存角度讲,全局变量所在内存空间中的数据就是共享资源,而内存空间的大小就是临界区。

        稍后将讨论哪些代码可能成为临界区,多个线程同时执行临界区代码时会产生哪些问题等内容。现阶段只需理解临界区的概念即可。根据临界区是否引起问题,函数可以分为以下两类:

  • 线程安全函数(Thread-safe function)
  • 非线程安全函数(Thread-unsafe function)

        线程安全函数被多个线程同时调用时不会引发问题。反之,非线程安全函数被多个线程同时调用时可能会引发问题。但这并非关于有无临界区的讨论,线程安全函数中同样可能存在临界区,只是在线程安全函数中,同时被多个线程调用时可通过一些措施避免问题的发生。

        幸运的是,大多数标准函数都是线程安全函数。更幸运的是,我们不用自己区分线程安全函数和非线程安全函数。因为这些操作系统平台在定义非线程安全函数的同时,提供了具有相同功能的线程安全函数。比如下面的函数:

#include <netdb.h>
#include <sys/socket.h>

//非线程安全函数
struct hostent *gethostbyname(const char *name);

//线程安全函数
int gethostbyname_r(const char *name,
               struct hostent *ret, char *buf, size_t buflen,
               struct hostent **result, int *h_errnop);

        线程安全函数的名称后缀通常为 _r。既然如此,多个线程同时访问的代码块中应该调用 gethostbyname_r,而不是 gethostbyname?当然!但这种方法会给程序员带来沉重的负担。幸好可以通过如下方法自动将 gethostbyname 函数调用改为 gethostbyname_r 函数调用!

声明头文件前定义 _REENTRANT 宏。

        gethostbyname 函数和 gethostbyname_r 函数的函数名和参数声明都不同,因此,这种宏声明方式拥有巨大的吸引力。另外,无需为了上述宏定义特意添加 #define 宏语句,可以在 gcc 编译源程序时通过添加 -D_REENTRANT 选项定义宏。示例如下:

gcc -D_REENTRANT mythread.c -o mthread -lpthread

下文的示例中编译线程相关代码时,均默认添加 -D_REENTRANT 选项。

3.4 工作(Worker)线程模型

下面我们将介绍常见多个线程的情况。下面给出此类示例。

        将要介绍的示例将计算 1 到 10 的和,但并不是在 main 函数中进行累加运算,而是创建2个线程,其中一个线程计算 1 到 5 的和,另一个线程计算 6 到 10 的和,main 函数只负责输出运算结果。这种方式的编程模型称为 “工作线程(Worker thread)模型”。计算1到5之和的线程与计算6到10之和的线程将成为main主线程管理的工人(worker)。最后,给出示例代码前先给出程序执行流程图,如下图 7 所示。

图7  示例thread3.c的执行流程
  •  thread3.c
#include <stdio.h>
#include <pthread.h>

void* thread_summation(void *arg);

int sum = 0;  //声明全局变量

int main(int argc, char *argv[])
{
    pthread_t tid1, tid2;
    int range1[]={1, 5};
    int range2[]={6, 10};
    
    pthread_create(&tid1, NULL, thread_summation, range1);
    pthread_create(&tid2, NULL, thread_summation, range2);
    
    pthread_join(tid1, NULL);
    pthread_join(tid2, NULL);
    printf("result: %d\n", sum);
    return 0;
}

void* thread_summation(void *arg)
{
    int start = ((int*)arg)[0];
    int end = ((int*)arg)[1];
    
    while(start <= end)
    {
        sum += start;
        start++;
    }
    return NULL;
}

        在 thread3.c 示例中,我们可以注意到:“两个线程可以直接访问全局变量sum。” 这是因为全局变量是存放在进程地址空间的数据区,是进程内的所有线程可以共享的内存区域。

  • 运行结果

$ gcc thread3.c -D_REENTRANT -o thread3 -lpthread
$ ./thread3
result: 55

        运行结果为55,虽然正确,但示例本身还是存在问题的。此处存在临界区相关问题,因此再介绍另一示例。该示例与 thread3.c 示例相似,只是增加了发生临界区相关错误的可能性,即使在高配置系统环境下也容易验证产生的错误。

  • thread4.c
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <pthread.h>

#define THREAD_NUM 100

void* thread_inc(void *arg);
void* thread_des(void *arg);
long long num = 0;  //long long 数据类型是8字节整型

int main(int argc, char *argv[])
{
    pthread_t tid[THREAD_NUM];
    int i;
    
    printf("sizeof(long long): %d(bytes)\n", sizeof(long long));  //查看long long的大小
    for(i=0; i<THREAD_NUM; i++)
    {
        if(i % 2 != 0)
            pthread_create(&tid[i], NULL, thread_inc, NULL);
        else
            pthread_create(&tid[i], NULL, thread_des, NULL);
    }
    
    for(i=0; i<THREAD_NUM; i++)
        pthread_join(tid[i], NULL);

    printf("result: %lld\n", num);
    return 0;
}

void* thread_inc(void *arg)
{
    int i;
    for(i=0; i<50000000; i++)
        num += 1;
    return NULL;
}

void* thread_des(void *arg)
{
    int i;
    for(i=0; i<50000000; i++)
        num -= 1;
    return NULL;
}

        示例 thread4.c 中共创建了100个线程,其中一半线程执行 thread_inc 函数中的代码,对全局变量 num 执行自增加1操作;另一半线程执行 thread_des 函数中的代码,对全局变量 num 执行自减减1操作。因此,全局变量 num 经过增减过程后,最后输出结果应为 0,通过运行结果观察是否真能得到我们期望的结果呢?

  • 运行结果

$ gcc thread4.c -D_REENTRANT -o thread4 -lpthread
$ ./thread4
sizeof(long long): 8(bytes)
result: 19774230
$ ./thread4
sizeof(long long): 8(bytes)
result: 39285629
$ ./thread4
sizeof(long long): 8(bytes)
result: 38318569

        从上面的运行结果可以看出,运行结果并不是 0。而且每次运行的结果均不同。虽然其原因尚不得而知,但可以肯定的是,这对于多线程的应用是个大问题。

四  线程存在的问题和临界区

4.1 多个线程访问同一共享变量存在的问题

        上文中的 thread4.c 示例中存在如下问题:

两个线程同时访问全局变量 num。

        此处的 “访问” 是指值的更改操作。虽然示例中访问的对象是全局变量,但这并非全局变量引发的问题。任何内存空间——只要被同时访问——都有可能发生问题。

不是说线程会分时使用CPU吗?那应该不会出现同时访问变量的情况啊。

        当然,此处的 “同时访问” 与我们所想的有一定区别。下面通过示例解释 “同时访问” 的含义,并说明为何会引起问题。假设两个线程要执行将变量 num 逐次加 1 的操作,如下图 8 所示。

图8  等待中的两个线程

        上图 8 中描述的是两个线程准备将变量 num 的值加 1 的情况。在此状态下,线程1将变量num的值增加到100,线程2再访问num时,变量num的值将按照我们的预想保存101。可事实果真如此吗?

需要说明的是,变量num是存储在内存中的,而线程是在CPU上运行的,对变量num执行加1操作可以分为如下三个步骤进行:

  • 取操作数num到CPU寄存器中。
  • 对寄存器中的操作数执行加1运算。
  • 将寄存器中的值写回到存放num变量的内存单元中。

以上三个步骤完成,才算执行完了一次  num += 1; 语句。这涉及到计算机组成原理相关的内容,可以查阅相关资料了解详情。

下图 9 是线程1将变量num加1之后的情形。

图9  线程的加法运算1-1

        图 9 中需要注意值的增加方式,值的增加需要CPU运算完成,变量num中的值不会自动增加。线程1首先需要读该变量的值并将其传递给CPU,获得加1之后的结果100,最后再把结果写回变量num,这样变量num的值就变成100。接下来给出线程2的执行过程,如下图 10 所示。

图10  线程的加法运算1-2

        线程2先从内存中取出变量num的值100,执行加1运算后将101写回给变量num。但这是最理想的情况。线程1完成增加num之前,线程2完全有可能通过线程切换得到CPU使用权。

        下面从头再来,下图 11 描绘的是线程1读取变量num的值并完成加1运算时的情况,即完成了 num += 1; 语句 的前两步,只是第三步加1后的结果尚未写回到变量num。

图11  线程的加法运算2-1

        接下来线程1就要将100写回到变量num中,但在执行这第三步操作前,执行流程跳转到了线程2,线程2从内存获取到变量num的值99,并完成了加1运算,并将加1之后的结果写回变量num,此时num的值变为100。如下图 12 所示。

图12  线程的加法运算2-2

        从上图 12 中可以看到,变量num的值尚未被线程1加到100,因此线程2读到的变量num的值仍为99,结果是线程2将变量num的值修改成100。还剩下线程1将运算后的值写回变量num的操作。接下来给出该过程,如下图 13 所示。

图13  线程的加法运算2-3

        此时,线程1将自己的运算结果100再次写入变量num,结果变量num变成100。可以看到,虽然线程1和线程2各做了一次加1运算,却得到了意想不到的结果。因此,当其中一个线程访问变量num时,应该阻止其他线程的访问,直到该线程完成运算为止。这就是同步(Synchronization)。从这个示例中我们可以意识到多线程编程中 “同步” 的必要性,也就能理解 thread4.c 示例中的运行结果了。

4.2 临界区位置

thread4.c 示例中我们可以发现:“同时运行多个线程时引起问题的是多条语句构成的代码块。

全局变量 num 是否应该视为临界区?不是!因为它不是引起问题的原因。该全局变量并非同时运行,只是代表内存区域的声明而已。临界区通常位于由线程运行的函数内部。下面观察示例 thread4.c 中的的两个线程函数。

void* thread_inc(void *arg)
{
    int i;
    for(i=0; i<50000000; i++)
        num += 1;  //临界区
    return NULL;
}

void* thread_des(void *arg)
{
    int i;
    for(i=0; i<50000000; i++)
        num -= 1;  //临界区
    return NULL;
}

        由上面的代码注释可知,临界区并非全局变量num本身,而是访问num的2条语句。这2条语句可能由多个线程同时运行,这是引发问题的直接原因。产生的问题可以整理为如下三种情况:

  • 两个线程同时执行 thread_inc 函数。
  • 两个线程同时执行 thread_des 函数。
  • 两个线程分别执行 thread_inc 和 thread_des 函数。

参考

《TCP-IP网络编程(尹圣雨)》第18章 - 多线程服务器端的实现

《Linux高性能服务器编程》第14章 - 多线程编程

《Linux典藏大系:Linux环境C程序设计(第2版)》第17章 - 线程控制

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

Linux网络编程 - 多线程服务器端的实现(1) 的相关文章

  • 基于Python的PROSAIL模型介绍以及使用

    1 介绍 PROSAIL是两种模型耦合得到的 SAIL是冠层尺度的辐射传输模型 xff0c 把冠层假设成是连续的且具有给定几何形状和密度的水平均匀分布的介质层 xff0c 从而模拟入射辐射与均匀介质之间的相互作用 xff0c 具体还是挺复杂
  • 可能是最全的FreeRTOS源码分析及应用开发系列

    可能是最全的FreeRTOS源码分析及应用开发系列 FreeRTOS 是一个可裁剪的小型且免费的 RTOS 系统 xff0c 尺寸非常小 xff0c 可运行于微控制器上 其特点包括 xff1a 内核支持抢占式 xff0c 合作式和时间片调度
  • 关于VS中LNK1120与errorLNK2019问题

    最近遇到了该问题 xff0c 再查找了一些资料后 xff0c 发现了针对自己问题的解决方法 xff0c 贴出来让大家一起学习一下 其实如果这两个问题同时出现 xff0c 很可能不是链接库缺了lib xff0c 而是编译中添加的源没有被实例化
  • PCL—低层次视觉—点云分割(基于凹凸性)

    转自 xff1a http www cnblogs com ironstark p 5027269 html PCL 低层次视觉 点云分割 xff08 基于凹凸性 xff09 1 图像分割的两条思路 场景分割时机器视觉中的重要任务 xff0
  • 【ENVI入门系列】13.分类后处理

    原文地址 xff1a ENVI入门系列 13 分类后处理 作者 xff1a ENVI IDL中国 版权声明 xff1a 本教程涉及到的数据提供仅练习使用 xff0c 禁止用于商业用途 目录 分类后处理 1 概述 2 分类后处理 2 1 小斑
  • ENVI神经网络工具参数和使用方法

    原文地址 xff1a ENVI神经网络工具参数和使用方法 作者 xff1a pengheligis xff08 1 xff09 Activation xff1a 选择活化函数 对数 xff08 Logistic xff09 和双曲线 xff
  • 详解使用pscp命令Linux文件上传与下载

    一 上传 2 开始 运行 cmd进入到 dos模式输入以下命令 以下是代码片段 xff1a pscp D java apache tomcat 5 5 27 webapps szfdc rardev 64 192 168 68 249 ho
  • java学习总结及心得体会

    前言 xff1a 哈哈 xff0c 今天是2015年 xff18 月 xff12 号 xff0c 星期日 xff0c 今天是收货的一天 xff0c 很开心 xff0c 很快乐 到底发生了什么呢 xff0c 容我慢慢来 世界很大 xff0c
  • 二进制的表白

    没能提起勇气对她进行表白 xff0c 只能寄托于0 1代码记录下对你的喜欢 01000101 01110110 01100101 01101110 00100001 01001001 00100000 01101100 01101111 0
  • C语言strcat()函数:连接字符串

    头文件 xff1a include lt string h gt strcat 函数用来连接字符串 xff0c 其原型为 xff1a char strcat char dest const char src 参数 dest 为目的字符串指针
  • linux 下TCP通信例程

    TCP server span class token macro property span class token directive hash span span class token directive keyword inclu
  • FreeRTOS系列|消息队列一

    消息队列一 1 消息队列简介 消息队列可以在任务与任务 任务与中断之间传递消息 xff0c 队列可以保存有限个具有确定长度的数据单元 队列可保存的最大单元数目被称为队列的长度 xff0c 在队列创建时需要指定其长度和每个单元 xff08 队
  • 【达内课程】网络通信之HTTP协议和Get请求实例(1)

    文章目录 HTTP协议发送HTTP GET请求增加参数 HTTP协议 HTTP协议 HTTP 协议是超文本传输控制协议 HTTP 协议中定义了客户端与服务端的通信流程与数据交互格式 网络通信中的长连接与短链接 长连接 xff1a 当客户端与
  • 学习Kalibr工具--Camera与IMU联合标定过程

    上一节介绍了 xff0c 用kalibr工具对camera进行标定的操作流程 xff0c 在camera标定之好之后 xff0c 进行camera与IMU进行联合标定的操作的学习 xff0c 即求取相机和IMU 之间的转换关系 坐标系之间的
  • C++之数据对齐

    目录 关于对齐数据对齐结构体对齐原则原理分析 关于对齐 对齐方式 xff1a 表示的是一个类型的对象存放的内存地址应满足的条件好处 xff1a 对齐的数据在读写上有性能优势对于不对齐的结构体 xff0c 编译器会自动补齐以提高CPU的寻址效
  • 对于人工智能,你有怎样的认识和理解?

    作为最初级的程序员 xff0c 对于高深的技术总是望尘莫及 xff0c 而高大上的人工智能更是让我们感觉遥远 xff0c 不过路都是一步步走出来的 xff0c 只要一直走 xff0c 总有触及到的一天 今天就来聊聊你对于人工智能的认识吧 x
  • windows通过独立ip形式访问docker容器

    windows10环境下通过docker容器独立ip暴露给局域网进行访问 自定义docker network docker network create subnet 61 172 20 0 0 24 mhy net 启动docker ng
  • Docker镜像

    概述 前面我们说了Docker的基本概念 xff0c 这里我们把每一块内容进行详细疏理一下 xff0c 本篇主要讲的是Docker的镜像相关内容 注 xff1a 本篇内容主要以 Docker从入门到实践 中镜像一模块为主线 xff0c 结合
  • Docker容器

    概述 前面我们讲了Docker三个主要概念中的镜像 xff0c 这里我们再来讲一下Docker的第二个重要概念 xff1a 容器 容器是独立运行的一个或一组应用以及它们的运行态环境 关于容器本篇主要讲如下几部分内容 xff1a 启动容器 关
  • 如何进入Docker容器

    概述 在使用Docker创建了容器之后 xff0c 大家比较关心的就是如何进入该容器了 xff0c 其实进入Docker容器有好几多种方式 xff0c 这里我们就讲一下常用的几种进入Docker容器的方法 进入Docker容器比较常见的几种

随机推荐

  • 从零开始使用Docker构建Java Web开发运行环境

    概述 前面我们讲了关于Docker的一些基本概念和操作 xff0c 今天我们以一个简单的Java Web例子来说一下Docker在日常工作中的应用 xff0c 本篇主要讲如下几部分内容 xff1a 创建jdk镜像 创建resin镜像 启动w
  • Spring MapFactoryBean应用详解

    在我们工作中 xff0c 尤其是电商系统中 xff0c 一个庞大的电商平台不是一个封闭的平台 xff0c 往往还伴生着一个开放平台 xff0c 用以接入各个企业 xff0c 以实现一种共赢的局面 xff0c 一般来讲 xff0c 针对于这种
  • FreeRTOS系列|二值信号量

    二值信号量 1 信号量简介 信号量一般用来进行资源管理和任务同步 xff0c FreeRTOS中信号量又分为二值信号量 计数型信号量 互斥信号量和递归互斥信号量
  • ubuntu ifconfig命令无效解决方案

    1 更新或升级系统 sudo apt get update 2 安装ipconfig的工具 sudo apt install net tools 3 查看ip ifconfig
  • 树莓派4b镜像烧录以及如何无显示屏远程登陆操作

    1 树莓派的烧录 xff1a 树莓派的烧录我用了很长的时间 xff0c 重新烧录的很多次 xff0c 都是因为没办法打开ssh xff0c 所以没办法进入树莓派调试 因为我使用树莓派主要是用来部署yolov5进行识别物体的 xff0c 所以
  • MyBatis入门——动态SQL

    前言 在我们日常工作中 xff0c 使用MyBatis除了做一般的数据查询之外 xff0c 还有很多业务场景下需要我们针对不同条件从数据库中获取到满足指定条件的数据 xff0c 这时候我们应该如何来做呢 xff1f 针对每种条件封装一个方法
  • Docker搭建本地私有仓库

    和Mavan的管理一样 xff0c Dockers不仅提供了一个中央仓库 xff0c 同时也允许我们使用registry搭建本地私有仓库 使用私有仓库有许多优点 xff1a 一 节省网络带宽 xff0c 针对于每个镜像不用每个人都去中央仓库
  • 斐波那契数列 Java实现

    关于斐波那契数列在百度百科上的定义如下 xff1a 斐波那契数列 xff0c 又称黄金分割数列 xff0c 指的是这样一个数列 xff1a 0 1 1 2 3 5 8 13 21 34 在数学上 xff0c 斐波纳契数列以如下被以递归的方法
  • Maven+Jetty运行项目无法热修改html处理

    一直以来都在做后端工程的开发 xff0c 很少做前端设计 xff0c 最近工作需要开始做前端开发 xff0c 感觉 辛辛苦苦几十年 xff0c 一朝回到解放前 的节奏啊 xff0c 遇到不少问题 xff0c 记录下来以备后查 今天在使用Ma
  • Spring4.3.0 Junit4.11 initializationError(org.junit.runner.manipulation.Filter)

    Spring4 3 0 Junit4 11 initializationError org junit runner manipulation Filter 昨天手欠 xff0c 在项目中把Spring3 2 14版本升级到4 3 0版本
  • zookeeper入门(一)——ZooKeeper伪集群安装

    zookeeper入门 xff08 一 xff09 ZooKeeper伪集群安装 在进行本篇文章之前 xff0c 先请大家了解一下zookeeper xff08 后面的文章为了省事有可能直接使用zk缩写来替代 xff09 xff0c 关于z
  • zookeeper入门(二)——zk客户端脚本使用

    zookeeper入门 xff08 二 xff09 zk客户端脚本使用 在上一篇文章zookeeper入门 xff08 一 xff09 ZooKeeper伪集群安装我们讲了在单机进行zk伪集群安装 xff0c 本篇文章我们来讲一下zk提供的
  • 事务基础知识

    数据库事务 数据库事务定义 xff0c 满足4个特性 xff1a 原子性 xff08 Atomic xff09 一致性 xff08 Consistency xff09 隔离性 xff08 Isolation xff09 和持久性 xff08
  • MySQL事务隔离级别

    1 MySQL所支持的事务隔离级别 MySQL所支持的事务隔离级别 xff1a READ UNCOMMITTED READ COMMITTED REPEATABLE READ SERIALIZABLE 其中 REPEATABLE READ是
  • Thrift第一个示例

    第一步 xff1a 引入thrift依赖包 compile span class hljs keyword group span span class hljs string 39 org apache thrift 39 span nam
  • FreeRTOS系列|计数信号量

    计数信号量 1 计数信号量简介 计数型信号量有以下两种典型用法 事件计数 xff1a 每次事件发生 xff0c 事件处理函数将释放信号量 xff08 信号量计数值加1 xff09 xff0c 其他处理任务会获取信号量 xff08 信号量计数
  • Redis学习——01.redis安装

    下载 tar xzvf redis span class hljs number 3 2 span span class hljs number 10 span span class hljs preprocessor tar span s
  • IDEA常用设置

    显示主题 建议使用Darcula Appearance gt Theme 编辑器字体 建议使用Courier New或者Consolas Editor gt Font gt Font 打开自动编译 Compiler gt Build pro
  • Windows下执行Linux命令

    常用的工具 Cygwin xff08 http www cygwin com xff09 Cygwin是一个在windows平台上运行的类UNIX模拟环境 xff0c 详细参见百度百科 xff1a https baike baidu com
  • Linux网络编程 - 多线程服务器端的实现(1)

    引言 本来 xff0c 线程在 Windows 中的应用比在 Linux 平台中的应用更广泛 但 Web 服务的发展迫使 UNIX 系列的操作系统开始重视线程 由于 Web 服务器端协议本身具有的特点 xff0c 经常需要同时向多个客户端提