欢迎光临
我们一直在努力

【Linux C语言】多线程编程实战:从试除法统计质数看 pthread 性能优化与踩坑指南

一、 课前预习:小白必懂的3个核心概念

在动手写代码前,我们需要先搞懂三个核心概念,这决定了你能不能看懂后续的代码逻辑。

1. 进程 vs 线程(工厂与工人的比喻)

  • 进程(Process):就像一个工厂。工厂有自己独立的厂房、水电资源(独立的内存空间)。
  • 线程(Thread):就像工厂里的工人。一个工厂里可以有多个工人同时干活。工人们共享工厂的资源(共享全局变量、堆内存),但每个工人有自己独立的工作台(独立的栈空间)。
  • 多线程:就是雇佣多个工人,把一个大任务拆成几份,大家同时干,从而缩短总耗时。

2. 什么是试除法?(6k±1 优化)

  • 效率不高:判断数字 n 是不是质数,从 2 一直除到 n-1。
  • 效率提高:其实只需要除到根号n 即可。如果 n 有一个大于根号n的因子,那它必定有一个小于根号n的因子。
  • 优化(6k±1):数学规律表明,大于3的质数,一定分布在 6的倍数 的两侧(即 6k-1 或 6k+1)。利用这个规律,我们可以直接跳过大量不必要的判断,将效率提升约 3 倍!

3. 什么是 pthread?

Linux 下写多线程,使用的是 POSIX 线程库(简称 pthread)。它提供了一堆 C 语言 API,让你能创建工人(pthread_create)、分配任务、等工人干完活(pthread_join)。


二、 作业1:单线程版本(1个工人干所有的活)

这个版本是基础,我们只用一个主线程(也就是程序本身)来遍历 2 到 1 亿,挨个判断。

#include <stdio.h> // 标准输入输出(printf)
#include <stdlib.h> // 标准库
#include <sys/time.h> // 用于获取高精度的时间(gettimeofday)

// 定义我们要统计的最大范围:1亿
// 注意:数字后面加 L 表示 Long(长整型),防止数字太大超出 int 范围导致溢出
#define MAX_N 100000000L

/**
* 【核心函数】:试除法判断一个数 n 是否为质数(6k±1 优化版)
* 返回值:1 表示是质数,0 表示不是质数
*/
int is_prime(long n) {
if (n <= 1) return 0; // 1. 小于等于1的数不是质数
if (n <= 3) return 1; // 2. 2和3是质数,直接返回
if (n % 2 == 0 || n % 3 == 0) // 3. 排除掉2和3的倍数(干掉一半以上的数字)
return 0;

// 4. 【6k±1 优化法】:只检查 6k-1 (i) 和 6k+1 (i+2)
for (long i = 5; i * i <= n; i += 6) {
if (n % i == 0 || n % (i + 2) == 0)
return 0;
}
return 1; // 5. 没找到因子,是质数
}

/**
* 【辅助函数】:获取当前的精确时间(秒 + 微秒)
* 为什么不用 time()?因为 time() 只能精确到秒,程序跑得快的时候测不准。
*/
double get_time() {
struct timeval tv;
gettimeofday(&tv, NULL);
return tv.tv_sec + tv.tv_usec / 1000000.0;
}

int main() {
double start = get_time(); // 记录开始时间
long count = 0;

// 从 2 遍历到 1亿,挨个判断
for (long i = 2; i <= MAX_N; i++) {
if (is_prime(i)) {
count++;
}
}

double end = get_time(); // 记录结束时间

printf("============================================\\n");
printf(" 【单线程】质数统计(试除法)\\n");
printf("============================================\\n");
printf(" 统计范围: [2, %ld]\\n", MAX_N);
printf(" 质数个数: %ld\\n", count);
printf(" 总耗时: %.3f 秒\\n", end – start);
printf("============================================\\n");

return 0;
}


三、 作业2:多线程版本

这个版本是重头戏。我们要把 1亿 个数字“切块”,分给多个线程同时去算。

1. 架构设计图

┌──────────────┐
│ 主线程 │
│ 创建N个工作线程│
└──────┬───────┘
┌───────────┬───┴────┬───────────┐
┌─────▼─────┐ ┌───▼────┐ ┌▼────────┐ ┌▼────────┐
│ 线程 0 │ │线程 1 │ │线程 2 │ │线程 N-1 │
│[2, 2500万]│ │[2500万,│ │[5000万, │ │[7500万, │
│ 试除判断 │ │ 5000万]│ │ 7500万] │ │ 1亿] │
│ 独立计数 │ │ 试除判断│ │ 试除判断│ │ 试除判断│
└─────┬─────┘ └───┬────┘ └────┬────┘ └────┬────┘
└───────────┴─────┬─────┴───────────┘
┌─────▼─────┐
│ 主线程 │
│ 汇总结果 │ (无锁化,避免数据竞争)
└───────────┘

2. 完整代码

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h> // 【重点】Linux 多线程编程必须引入的头文件
#include <sys/time.h>

#define MAX_N 100000000L

// ============== ⚠️ 避坑关键:函数前置声明 ==============
// 在 C 语言中,如果函数定义在 main 之后,必须在这里先声明,否则链接时会报错!
int is_prime(long n);
double get_time();
// ====================================================

int is_prime(long n) {
if (n <= 1) return 0;
if (n <= 3) return 1;
if (n % 2 == 0 || n % 3 == 0)
return 0;
for (long i = 5; i * i <= n; i += 6) {
if (n % i == 0 || n % (i + 2) == 0)
return 0;
}
return 1;
}

double get_time() {
struct timeval tv;
gettimeofday(&tv, NULL);
return tv.tv_sec + tv.tv_usec / 1000000.0;
}

/**
* 【重点结构体】:线程参数包
* pthread_create 只能传递【一个】 void* 指针参数。
* 传多个数据(起点、终点)必须打包成结构体!
*/
typedef struct {
long start; // 本线程负责判断的起始数字
long end; // 本线程负责判断的结束数字
long local_count; // 本线程统计的质数个数(用于将结果带回给主线程)
} ThreadArgs;

// is_prime 和 get_time 的实现与单线程完全一致,此处省略以节省篇幅…
int is_prime(long n) {
if (n <= 1) return 0;
if (n <= 3) return 1;
if (n % 2 == 0 || n % 3 == 0) return 0;
for (long i = 5; i * i <= n; i += 6) {
if (n % i == 0 || n % (i + 2) == 0) return 0;
}
return 1;
}
double get_time() {
struct timeval tv;
gettimeofday(&tv, NULL);
return tv.tv_sec + tv.tv_usec / 1000000.0;
}

/**
* 【线程工作函数】:每个线程(工人)实际执行的代码
*/
void *count_primes(void *arg) {
ThreadArgs *args = (ThreadArgs *)arg; // 1. 强转回结构体指针
long count = 0;

// 2. 只遍历分配给自己的区间 [start, end]
for (long i = args->start; i <= args->end; i++) {
if (is_prime(i)) {
count++;
}
}

// 3. 把算好的结果,存回结构体中(主线程就能看到了)
args->local_count = count;
return NULL; // 4. 线程结束
}

int main(int argc, char *argv[]) {
if (argc != 2) {
fprintf(stderr, "用法: %s <线程数>\\n", argv[0]);
return 1;
}

int num_threads = atoi(argv[1]);
if (num_threads <= 0 || num_threads > 64) {
fprintf(stderr, "线程数范围: 1 ~ 64\\n");
return 1;
}

double start_time = get_time();

// ===== 第1步:给每个线程分配任务(切蛋糕) =====
pthread_t *threads = malloc(num_threads * sizeof(pthread_t));
ThreadArgs *args = malloc(num_threads * sizeof(ThreadArgs));

for (int i = 0; i < num_threads; i++) {
// 【核心算法】:将 [2, MAX_N] 均匀切成 num_threads 份
args[i].start = 2 + (MAX_N – 1) * i / num_threads;
args[i].end = 2 + (MAX_N – 1) * (i + 1) / num_threads – 1;

if (i == num_threads – 1) args[i].end = MAX_N; // 防止漏掉最后一个数
args[i].local_count = 0;
}

// ===== 第2步:正式创建线程(招募工人开工) =====
for (int i = 0; i < num_threads; i++) {
int ret = pthread_create(&threads[i], NULL, count_primes, &args[i]);
if (ret != 0) {
fprintf(stderr, "线程创建失败!\\n");
return 1;
}
}

// ===== 第3步:主线程等待所有子线程干完活 =====
for (int i = 0; i < num_threads; i++) {
// pthread_join 让主线程“阻塞”,直到第 i 个线程执行完毕
pthread_join(threads[i], NULL);
}

// ===== 第4步:汇总所有线程的结果 =====
long total_count = 0;
for (int i = 0; i < num_threads; i++) {
total_count += args[i].local_count;
}

double end_time = get_time();

printf("================================================\\n");
printf(" 【多线程】质数统计(试除法)\\n");
printf("================================================\\n");
printf(" 线程数: %d\\n", num_threads);
printf(" 统计范围: [2, %ld]\\n", MAX_N);
printf(" 质数个数: %ld\\n", total_count);
printf(" 总耗时: %.3f 秒\\n", end_time – start_time);
printf("================================================\\n");

printf("\\n —- 各线程工作详情 —-\\n");
for (int i = 0; i < num_threads; i++) {
printf(" 线程 %d: 负责 [%ld, %ld] -> 找到 %ld 个质数\\n",
i, args[i].start, args[i].end, args[i].local_count);
}

free(threads);
free(args);
return 0;
}

💡 核心亮点解析

  • 为什么要用 ThreadArgs 结构体? pthread_create 只允许传一个 void *arg 参数。我们要传 start 和 end 两个值,必须“打包”进结构体传指针。
  • 不修改全局变量,完美避开“数据竞争”! 如果多个线程同时给全局变量 total_count++,会引发数据丢失(Data Race),通常需要加互斥锁(Mutex)。但本代码中,子线程只修改自己结构体里的 local_count(私有内存),等所有线程 join 结束后,主线程再统一累加。这种无锁化设计既安全又高效!

  • 四、 编译运行与真实踩坑指南

    1. 正确的编译命令

    # 编译单多线程
    gcc 你的文件的名字 记住要加后缀 例如 a.c -o 生成的程序名字

    2. 🚨 真实踩坑记录:undefined reference 错误

    很多新手在编译多线程代码时,会遇到如下满屏飘红的报错:

    /usr/bin/ld: /tmp/ccEN5kAO.o: in function `count_primes':
    d1.c:(.text+0x35): undefined reference to `is_prime'
    /usr/bin/ld: d1.c:(.text+0xf4d): undefined reference to `get_time'
    collect2: error: ld returned 1 exit status

    🔍 为什么会报错? 这是链接阶段的错误。在 C 语言中,如果函数的定义放在了 main 函数的后面,且在文件开头没有进行函数声明,编译器在自上而下扫描时就不认识这个函数,导致最终链接器找不到对应的符号。

    ✅ 解决方案: 永远养成好习惯,在 #include 头文件之后,立刻写上函数声明:

    #include <stdio.h>
    // … 其他头文件

    // 👇 加上这两行,彻底告别 undefined reference!
    int is_prime(long n);
    double get_time();

    int main() { … }


    五、程序运行数据结果

    1. 单线程与四线程

    这是8核单线程下的总消耗时间

    这是8核四线程下的总消耗时间

    这是8核八线程下的总消耗时间

    2. 为什么 8 线程没有达到 8 倍加速?

    当开启的线程数(8)超过了 CPU 的物理核心数(4) 时,操作系统需要频繁地在多个线程之间进行上下文切换(Context Switch)。保存和恢复寄存器状态需要消耗额外的 CPU 时间,这就是所谓的“边际效益递减”。这证明了:线程数并非越多越好,匹配物理核心数才是最优解。


    六、 总结

  • 算法层面:掌握了试除法及 6k加减1 的优化技巧。
  • 系统层面:使用了 pthread_create 和 pthread_join 进行 Linux 多线程编程。
  • 架构层面:通过“结构体传参”和“局部变量汇总”来实现无锁化并发设计,巧妙避开了数据竞争。
  • 排错层面: C 语言函数声明机制,解决了 undefined reference 链接错误。
  • 赞(0)
    未经允许不得转载:171主机测评 » 【Linux C语言】多线程编程实战:从试除法统计质数看 pthread 性能优化与踩坑指南
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址