1.线程与进程的关系
执行流(Execution Flow)通常指的是程序执行过程中的控制路径,它描述了程序从开始到结束的指令执行顺序。例如我们要有两个执行流来分别进行加法和减法的运算,我们可以通过使用 fork 函数来创建子进程,父进程进行加法运算,子进程进行减法的运算来完成任务,但是创建一个进程需要额外创建PCB
,进程地址空间,页表等等,代价过大效率过低,于是出现了线程的概念。
在Linux系统中,线程是操作系统能够进行运算调度的最小单位,它被包含在进程之中。每个线程都有自己的运行状态,包括程序计数器、寄存器集和栈。
在Linux内核中,并没有严格地区分进程和线程,它们都是通过task_struct
数据结构来表示的。每个进程或线程都有一个对应的task_struct
实例,其中包含了该进程或线程的许多重要信息。因此,可以将线程看作是共享进程地址空间的轻量级进程。
线程是进程内的执行单元。在同一进程中创建的所有线程都共享相同的地址空间、全局变量和其他资源。这意味着,线程间数据交换的成本较低,不需要额外的复制操作。然而,这同时也意味着,任何一个线程对共享资源的更改都会直接影响到其它线程。
进程 = 内核数据结构 + 代码和数据
上图中,多个PCB,进程地址空间,页表等都属于进程的内核数据结构,因此这些所有内容加起来,才算做进程。
线程 = 内核数据结构 + 共享的代码和数据
进程是承担系统资源分配的基本实体,操作系统分配资源时,以进程为单位,当一个进程拿到资源后,再去分配给不同的线程。线程和进程有如下图的关系:
#include <iostream>
#include <string>
#include <vector>
#include <pthread.h>
#include <unistd.h>
using namespace std;
int gval = 100;
void *ThreadRountine(void *args)
{char* name = static_cast<char*>(args);cout << name << " is running,&gval= " << &gval << endl;return nullptr;
}
int main()
{vector<pthread_t> threads;for (int i = 0; i < 5; i++){pthread_t tid;string name = "Thread" + to_string(i);pthread_create(&tid, nullptr, ThreadRountine, (void *)name.c_str());sleep(1);}return 0;
}
我们可以看到多个进程使用的是同一个gval,即线程共享相同的地址空间、全局变量和其他资源。
当然如果我们想要在不同的线程里创建全局变量,即全局变量线程各自有一份,就需要用到关键字thread_local或
__thread(thread前有两个下划线)。
推荐使用thread_local,在 C++11 及以后的版本中,thread_local 关键字用于声明线程局部变量。这些变量在每个线程中都有独立的实例,它们的生命周期与线程的生命周期绑定。thread_local 变量在第一次使用时初始化,并在线程结束时自动销毁。使用 thread_local 变量可以避免数据竞争和不必要的同步,因为每个线程操作的是自己的数据副本。
thread_local int gval = 100;
2.Thread Control Block (TCB)
在Linux
中,由于线程和进程的内核数据结构相似,所以两者共用一套PCB数据结构来表示,但大部分操作系统描述管理线程,并不是使用PCB
,而是额外设计了Thread Control Block (TCB)。
Thread Control Block (TCB) 是操作系统中用于管理和控制线程的数据结构。在上图中,多个TCB
分别控制不同部分的代码,CPU
调度线程时,也去调度TCB
。比如主流的Windows
,MacOS
等操作系统,都使用这样的方式。它是线程在系统中的核心表示,存储了线程的关键属性、状态信息以及与线程生命周期管理相关的数据。TCB 包含了以下主要信息:
- 线程标识符 (TID):唯一标识线程的编号。
- 线程状态:如就绪、运行、阻塞、结束等,指示线程当前的活动状态。
- 调度信息:包括线程的优先级、时间片、调度策略等,这些信息决定了线程何时以及如何被调度执行。
- 上下文保存:包括CPU寄存器状态(如程序计数器、通用寄存器、状态寄存器等),这些信息在线程切换时需要保存和恢复。
- 资源关联:关联了线程使用的系统资源,如私有堆栈、共享资源的锁定状态、等待队列链接等。
3. 轻量级进程 LWP
在Linux中,一个进程可以有多个PCB,当PCB的数目为1,那么这个PCB可以代表一个进程;如果PCB数目有多个,那么这个PCB代表一个进程的多个线程。因此在Linux中,没有真正的线程,一个执行流由一个PCB维护。Linux把这种介于线程与进程之间的状态,称为轻量级进程 LWP (Light Weight Process)。
4. 进程资源分配给线程的机制
进程通过页表机制实现虚拟地址空间到物理地址空间的映射,从而管理和分配资源。在多线程的场景中,尽管有多个执行流(线程),但它们共享同一进程的虚拟地址空间和页表。这意味着所有线程可以访问进程的代码段、数据段、堆和栈等资源,而无需为每个线程单独分配这些资源。
当创建一个新线程时,它并不需要一个全新的页表,而是继承其父进程的页表。这样,线程可以直接使用进程已经映射到物理内存的虚拟地址。这种共享页表的方式极大地减少了内存的消耗,并且简化了线程的创建和管理过程。
不论是内存还是磁盘,都被划分为了以4kb为单位的数据块,一个数据块可以被称为页框 / 页帧。
操作系统管理内存,或者管理磁盘,都是以4kb为基本单位的。比如把磁盘中的数据加载到内存中,就是以4kb为基本单位进行拷贝。页框是被struct page管理的,Linux 2.6.10中,struct page源码如下:
struct page
{page_flags_t flags; atomic_t _count; atomic_t _mapcount; unsigned long private; struct address_space *mapping;pgoff_t index;struct list_head lru; #if defined(WANT_PAGE_VIRTUAL)void *virtual;
#endif
};
操作系统想要管理所有的页框,只需要创建一个数组,数组的元素类型是struct page。此时操作系统对内存或磁盘的管理,就变成了对数组的增删查改。而且从上方的struct page源码中可以发现,它是不存储页框的起始地址和终止地址,因为可以通过下标计算出起始地址,起始地址 + 4kb就可以求出终止地址。
我们以32
位操作系统为例,页表的结构如下:
页表的任务是把虚拟地址解析为物理地址,当传入一个虚拟地址,页表就要对其解析。一个32位的地址,会被分为三部分,第一部分是前10位,第二部分是中间10位,第三部分是末尾12位。
第一部分就是上图中的深蓝色部分,其由页目录进行解析。2^10 = 1024,即前10位地址有1024种可能,而页目录就是一个长度为1024的数组。解析地址时,先通过前10位,在页目录中找到对应的下标。每个页目录的元素,指向一个页表。
第二部分是上图中的黄色部分,其由页表进行解析,同样的 2^10 = 1024,即中间10位地址也1024种可能,所以每个页表的长度也是1024。解析中间10位时,在页表中找到对应的下标,从而找到对应的内存。
第三部分时上图中的绿色部分。一个数据块大小是4 kb,这是内存管理的基本单位。而 2^12 byte = 4 kb,因此第三部分也叫做页内偏移,通过前两个部分,我们已经可以锁定到内存中的一个页框了,而第三部分存储的是物理地址相对于页框起始地址的偏移量,此时就可以根据起始地址 + 偏移量来确定一个地址。
以上就是页表解析地址的全过程。
5. 线程控制
Linux系统并不像某些其他操作系统那样提供由内核直接提供的线程库,因为本质上Linux是没有线程的,而是通过轻量级进程来模拟线程,所以Linux是在用户层实现了一套多线程方案,以库的方式提供给用户使用。最常用的线程库是POSIX线程库(pthread),它提供了创建、终止、同步和调度等一系列函数来管理线程。
故而在使用gcc / g++
编译时,要带上选项 -l pthread
,来引入原生线程库。例如:
g++ -o test.exe test.cpp -l pthread
线程创建 (pthread_create
)
pthread_create
函数是POSIX线程库(pthreads)中用于创建新线程的标准接口,包含在头文件<pthread.h>
中。它允许用户指定线程的属性、线程函数及其参数,并返回新创建线程的标识符TID。这个函数在多线程编程中非常重要,因为它是启动新执行流程的基础。
参数:
pthread_t *thread
:指向一个pthread_t
类型的变量的指针,该变量将接收新创建线程的标识符TID。const pthread_attr_t *attr
:指向线程属性的指针。如果为NULL
,则使用默认属性。void *(*start_routine) (void *)
:线程函数的入口点,其签名必须匹配此形式,接受一个void *
类型的参数并返回同样类型的值。void *arg
:传递给线程函数的参数。
返回值:
- 如果创建成功,返回
0
- 如果创建失败,返回错误码
#include <iostream>
#include <pthread.h>
#include <string>
#include <unistd.h>
using namespace std;
void *ThreadRountine(void *arg)
{string name = static_cast<char*>(arg);while (1){cout << "I am " << name << ",mypid is " << getpid() << endl;sleep(1);}return nullptr;
}
int main()
{pthread_t tid;pthread_create(&tid, nullptr, ThreadRountine, (void *)"Thread-1");while (1){cout << "I am Thread_main,mypid is " << getpid() << endl;sleep(1);}return 0;
}
在上述代码中,我们创建了一个线程 Thread-1,并且在主线程和新线程都在循环输出一段话,而输出的语句中我们发现主线程和新线程的 pid 相同,原因是他们在同一进程中,故 pid 相同,而我们区分不同线程是通过 tid 。
pthread_self
pthread_self
是 POSIX 线程库中的一个函数,它用于获取调用该函数的线程的唯一标识符。这个标识符是 pthread_t
类型的,可以用于识别和区分同一进程中的不同线程。pthread_self
在多线程编程中非常有用,尤其是在需要进行线程同步或管理线程资源时。
#include <iostream>
#include <pthread.h>
#include <string>
#include <unistd.h>
using namespace std;void *ThreadRountine(void *arg)
{string name = static_cast<char *>(arg);while (1){cout << "I am " << name << ",mypid is " << getpid() << ",mytid is" << pthread_self()<< endl;sleep(1);}return nullptr;
}
int main()
{pthread_t tid;pthread_create(&tid, nullptr, ThreadRountine, (void *)"Thread-1");while (1){cout << "I am Thread_main,mypid is " << getpid() << ",mytid is" << pthread_self() << endl;sleep(1);}return 0;
}
我们发现线程 tid 很大,因为这个 tid 表示的是线程在内存的物理地址。
我们知道线程控制是通过pthread
库实现的。在pthread
动态库中线程被结构体描述,同时再被数据结构组织。
线程终止
pthread_exit
pthread_exit
是 POSIX线程库中的一个重要函数,用于从线程中退出。相比于简单地让函数返回,pthread_exit
允许传递一个 exit status 给可能正在等待该线程结束的其他线程或函数。
参数:
retval
: 此参数是可选的,通常用来向主线程或其他等待该线程结束的线程传递退出状态。我们知道线程创建时函数的返回值为void*类型,retval就是用来终止线程然后代替返回值。
pthread_cancel
pthread_cancel
是 POSIX 线程库中的一个函数,用于向指定的线程发送取消请求。当一个线程收到取消请求后,它将进入取消状态,并根据线程的取消类型和取消点的设置,决定取消请求的处理方式。线程可以配置为在到达取消点时立即响应取消请求,或者延迟响应直至下一个取消点。
参数:
thread
: 要发送取消请求的线程的 TID。
返回值:
- 如果函数成功发送取消请求,返回 0;
- 如果发送取消请求失败,返回非零值。
pthread_exit
函数用于使当前线程正常退出,pthread_cancel
函数用于向另一个线程发送取消请求。
线程等待(pthread_join)
pthread_join
函数是 POSIX 线程库中的一个同步函数,它用于等待一个或多个线程终止。当一个线程调用 pthread_join
并传递另一个线程的 ID 时,调用该函数的线程将被阻塞,直到被等待的线程结束执行。一旦被等待的线程终止,pthread_join
函数返回,并且可以选择性地获取该线程的退出状态。如果不需要获取退出状态,可以将第二个参数设置为 NULL
.
参数:
thread
:等待的线程的TID
retval
:输出型参数,线程退出后,该参数会接收到退出线程的函数返回值
返回值:
- 如果等待成功返回 0
- 如果失败返回错误码
#include <iostream>
#include <pthread.h>
#include <string>
#include <unistd.h>
using namespace std;
void *ThreadRountine(void *arg)
{string name = static_cast<char *>(arg);int cnt=3;while (cnt--){cout << "I am " << name << ",mypid is " << getpid() << ",mytid is " << pthread_self()<< endl;sleep(1);}return (void*)123456;
}
int main()
{pthread_t tid;pthread_create(&tid, nullptr, ThreadRountine, (void *)"Thread-1");void* retval;pthread_join(tid,&retval);printf("Thread-1 return value is %lld\n",(long long)retval);return 0;
}
线程分离(pthread_detach)
pthread_detach
函数用于将一个线程设置为分离状态(detached state)。在分离状态下,当线程结束时,它所占用的资源会自动被操作系统回收,无需其他线程显式调用 pthread_join
来等待其结束。这意味着,一旦线程被设置为分离状态,它就不再与创建它的线程绑定,可以独立存在直至结束。
参数:
thread
:要设置为分离状态的线程的标识符TID。
返回值:
- 如果函数成功,返回值为
0
。 - 如果函数失败,返回对应的错误码。
使用 pthread_detach
的场景通常是在那些不需要等待线程结束或者不关心线程退出状态的情况下。例如,在一个长时间运行的服务器程序中,主线程可能会创建多个子线程来处理客户端请求,而主线程本身需要继续监听新的请求。在这种情况下,可以在子线程的执行函数中通过调用 pthread_detach(pthread_self())
来设置子线程为分离状态,这样即使主线程不等待子线程结束,子线程在完成任务后也能正确地释放资源.
#include <iostream>
#include <pthread.h>
#include <string>
#include <unistd.h>
using namespace std;
void *ThreadRountine(void *arg)
{pthread_detach(pthread_self());string name = static_cast<char *>(arg);int cnt=3;while (cnt--){cout << "I am " << name << ",mypid is " << getpid() << ",mytid is " << pthread_self()<< endl;sleep(1);}return (void*)123456;
}
int main()
{pthread_t tid;pthread_create(&tid, nullptr, ThreadRountine, (void *)"Thread-1");return 0;
}