ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

C语言字符串分割:strtok与strtok_r的区别及多线程安全用法

2026/9/8 18:35:55 拓冰建站 浏览量
C语言字符串分割:strtok与strtok_r的区别及多线程安全用法 聊到 C 语言的字符串切割strtok和strtok_r绝对是最常被提起的一对名字。新手第一次写解析逻辑时基本都见过strtok用法简单、代码量少几行就能把一串逗号分隔的数据拆出来。可等到项目里开始上多线程或者需要在循环里对每一段再做二次拆分时问题就一个一个冒出来了解析结果莫名变少、程序跑着跑着崩溃、甚至陷入了死循环。说实话我第一次在代码里看到这些现象时也懵了很久后来才把这两个函数的实现细节彻底摸清楚。这篇文章不打算只贴手册我直接把strtok的分词机制、它为什么会“坑人”、strtok_r到底解决哪些问题以及不同场景下怎么选型这几个方面摊开讲。适合正在写 C/C 解析逻辑、或者被字符串切分问题困扰过的朋友参考。内容会尽量保持粗浅又实在能直接照着改代码那种。1. strtok的分词原理看上去简单实际上有三个隐藏规则1.1 它不是“截取”而是“原地修改”很多人用strtok的直觉是它会把字符串按分隔符“切开”然后分别返回每一段。这个理解方向没错但实现方式和新手想的很不一样——它不是复制出子串返回而是在传入的字符串内部把分隔符所在的位置替换成字符串结束符\0。比如这段代码#include stdio.h #include string.h int main(void) { char src[] apple,banana,cherry; char *tok strtok(src, ,); while (tok) { printf([%s]\n, tok); tok strtok(NULL, ,); } return 0; }第一次调用strtok(src, ,)返回apple时内存里的src已经从原来的apple,banana,cherry变成了apple\0banana\0cherry也就是说src的内容被改变了原来每个逗号的位置都变成了\0。这个特性带来的直接后果是解析完成后原始字符串已经残缺没法再当作完整数据使用。如果后面还有逻辑需要原串就必须先复制一份再切或者自己去手动维护原始数据。因为这个原因传入strtok的字符串必须是可写内存。你如果偷懒传一个字符串字面量比如char *tok strtok(apple,banana, ,);程序在运行时往往直接段错误。原因是字符串字面量通常存放在只读数据段strtok尝试往里写\0时立刻触发非法写入。很多人第一次用的时候就在这个位置栽过跟头。更隐蔽的是如果编译器优化或者平台行为不一样这个错误不一定每次都崩有时候是偶发性的排查起来特别痛苦。我的建议很简单只要不是char[]数组传入前一律用strdup或自己malloc复制一份用完记得free。1.2 连续分隔符会被忽略空字段直接消失strtok第二个容易让人误会的规则是它会把连续的多个分隔符当作一个来处理而且完全跳过字符串开头的分隔符。看这个例子#include stdio.h #include string.h int main(void) { char src[] ,,alpha,,beta,gamma, ; char *p strtok(src, , ); while (p) { printf([%s]\n, p); p strtok(NULL, , ); } return 0; }输出是[alpha] [beta] [gamma]字符串开头那些空格和逗号没有产生空 tokenalpha和beta之间的两个连续逗号也没有产生空 token结尾同样如此。这个行为对大多数“把词拆出来”的需求来说非常友好但一旦遇到严格格式比如 CSV 中的空字段就完全不行了。举个例子CSV 一行可能是name,,age,30里面有意的空字段需要被保留下来表示某个列没有值。strtok遇到连续逗号会直接把空列“吞掉”导致后面所有列的位置错位最终整个解析结果都是错的。这种情况下需要用的是strsep或者干脆别用这些现成函数自己写一个能正确处理空字段的解析器后面第 4 部分会详细对比。1.3 幕后那个静态指针才是一切问题的根源strtok最经典的设计是它内部维护了一个静态指针用来记住上一次解析到哪个位置了。第一次调用时传入待解析字符串之后每次调用都传NULL函数就知道“哦继续上次的位置往下找”。glibc 里的实现其实就几行char * strtok (char *s, const char *delim) { static char *olds; return strtok_r (s, delim, olds); }关键在于static char *olds。这个olds是整个进程内唯一的——它不属于某个线程也不属于某次独立解析。所以只要有另一个线程、或者同一个线程里的另一段逻辑再次调用strtok这个全局状态就会被覆盖之前正在进行的解析就“断片”了。这就是strtok被称为“不可重入”函数的原因。熟悉操作系统概念的朋友可以把“可重入”理解成函数能否在中间被打断或者被再次调用后依然正确完成任务。strtok因为有共享的静态状态一旦并发进入就会互相干扰。C 标准里也明说了它不需要保证线程安全POSIX 标准同样标记为线程不安全。换句话讲你在多线程程序里随便用strtok出问题是“正常”不出问题只是运气好。2. strtok_r到底解决了什么两个典型“翻车现场”2.1 翻车一多线程环境下解析结果错乱先讲一个我实际遇到的例子。之前写过一个日志分析服务开了 8 个线程并行解析不同的日志行每行格式是时间|级别|线程ID|消息内容代码里很自然地用strtok按|切分。单线程跑一切正常多线程一上就开始间歇性出现奇怪现象同一个函数里有的线程突然拿不到 token有的线程把上一行日志的内容拼到了下一行里。原因说起来也简单线程 A 第一次调用strtok时把全局olds设置成了自己缓冲区里的某个位置。线程 B 也调用strtok把olds覆盖成了另一个位置。A 下次再调用strtok(NULL, ...)时会顺着 B 留下的位置继续找解析出来的自然就是莫名其妙的另一个字符串片段。遇到这种问题千万不要试着用加锁解决。给每次调用strtok的过程加锁确实能避免数据竞争但会牺牲并发性而且逻辑稍一复杂就会漏锁。正确做法就是换用strtok_r让每个线程自己提供独立的保存指针。#include pthread.h #include stdio.h #include string.h struct task { char line[256]; }; static void *worker(void *arg) { struct task *t (struct task *)arg; char *save NULL; char *tok strtok_r(t-line, |, save); while (tok) { printf([%lu] %s\n, (unsigned long)pthread_self(), tok); tok strtok_r(NULL, |, save); } return NULL; }每个线程里的save都是栈上的局部变量strtok_r解析过程的状态存在这个变量里线程之间互不干扰。比起靠平台实现“碰运气”显式传入保存状态的做法在逻辑上更清晰也能跨平台移植。2.2 翻车二循环里嵌套调用strtok导致死循环或漏数据第二个经典翻车现场是嵌套解析。比如数据格式是useralice,age30;userbob,age25。第一层按;把人拆开第二层再对每个人按,拆字段第三层还要按拆键值。很多人很自然地在strtok的循环体里面又调了一次strtokchar conf[] useralice,age30;userbob,age25; char *record strtok(conf, ;); while (record) { char *field strtok(record, ,); // 内层重开一个解析 while (field) { // 处理字段 field strtok(NULL, ,); } record strtok(NULL, ;); // 这时外层状态已经被清掉了 }这段代码的问题很要命内层第一次调用strtok(record, ,)时因为传人的record不是NULL函数会认为“这是一次新的解析”直接把外层解析里保存的那个全局静态指针整体重置了。原本外层正在;后面等着的状态彻底丢失。等内层循环结束再调用strtok(NULL, ;)时它已经不是从外层的断点继续了而是从内层解析结束的位置继续找分号。结果要么漏掉一大段数据要么陷入死循环行为完全不可预知。当时的排查过程很折磨人因为单线程、单层解析时完全复现不出来只有数据里恰好包含需要二次切分的字段时才出问题而且每次都像随机的。换成strtok_r就清爽了外层用outer_save内层用inner_save第二层内层再用kv_save每一层各管各的保存指针互不干扰。具体完整代码在第 3 部分里给出。2.3 字符串字面量直接传入段错误警告不管是strtok还是strtok_r都会修改传入的原始字符串。有些同学知道这一点但在传getenv()的返回值时忘了注意——getenv返回的指针指向进程环境变量存储区通常也不应该被修改。如果在拿到路径字符串后直接丢给strtok_r去切:可能破坏环境变量本身也可能触发段错误。正确做法是先把结果复制出来再解析char *path getenv(PATH); char *copy strdup(path ? path : ); char *save NULL; char *dir; if (!copy) return; dir strtok_r(copy, :, save); while (dir) { printf(%s\n, dir); dir strtok_r(NULL, :, save); } free(copy);这个习惯一定要养成。判断“能不能直接传给strtok族函数”的标准只有一个这份内存是否可写以及你是否能接受它被改写。字符串字面量不行、const修饰的缓冲区不行、指向环境变量的内存原则上也不行。凡是存在疑问先复制一份总没错代价只是多一次内存分配但能省掉大量调试时间。3. 实战strtok_r在配置解析与线程任务中的完整用法3.1 三层嵌套解析同时处理分号、逗号、等号前面提过的嵌套结构这里给一个完整可运行版本。数据长这样useralice,age30;userbob,age25;usercarl,age28我的目标是拆出每条 user 记录并输出用户名 - 年龄。代码实现如下#include stdio.h #include string.h int main(void) { char conf[] useralice,age30;userbob,age25;usercarl,age28; char *outer_save NULL; char *record; char *outer strtok_r(conf, ;, outer_save); while (outer) { char *inner_save NULL; char *field strtok_r(outer, ,, inner_save); while (field) { char *kv_save NULL; char *key strtok_r(field, , kv_save); char *value strtok_r(NULL, , kv_save); if (key value) { printf(%s - %s\n, key, value); } field strtok_r(NULL, ,, inner_save); } outer strtok_r(NULL, ;, outer_save); } return 0; }运行输出user - alice age - 30 user - bob age - 25 user - carl age - 28这里关键的区别在于三层解析各自拥有独立的save变量outer_save、inner_save、kv_save。外层的outer_save记录按;切分时的进度内层的inner_save记录按,切分时的进度最内层的kv_save用来按拆键值。它们互不修改所以嵌套多少层都安全。不过也要提醒一点strtok_r仍然是在原地修改字符串分隔符替换成\0的执行过程是逐步发生的。上面例子中外层解析时已经把所有;替换成了\0所以在处理每条记录时outer就是我们能看到的完整独立子串内层解析只需要关注这条子串内部的逗号和等号即可。这能正常工作前提是你得理解每一层的修改只影响当前缓冲区。3.2 多线程并发场景下的安全改造多线程场景下改造的核心思路就是把原来由strtok内部维护的静态状态改成由每个线程自己维护的局部变量。我已经在第 2 部分给过一个简单示例这里再扩展一下假设有一个固定线程池每个线程从任务队列里取一行字符串做解析。任务结构本身是只读的所以在 worker 里先strdup一份再解析。#include pthread.h #include stdio.h #include stdlib.h #include string.h #define MAX_LINE 256 struct job { char line[MAX_LINE]; }; static void *process_line(void *arg) { struct job *j (struct job *)arg; char *copy strdup(j-line); char *save NULL; char *tok; if (!copy) return NULL; for (tok strtok_r(copy, ,, save); tok ! NULL; tok strtok_r(NULL, ,, save)) { printf(thread %lu - %s\n, (unsigned long)pthread_self(), tok); } free(copy); return NULL; }每个线程的save都是独立栈变量copy也都是自己malloc出来的天然不存在交叉污染。这个模式在 Redis、Nginx 这类 C 项目的源码里也经常能看到——凡是需要切分字符串的地方出现的基本都是strtok_r而不是裸的strtok。强调一句如果你看到别人在共享的解析函数里用strtok并且这个函数会被多个线程调用那这基本属于必现 Bug只是触发时机不确定。能改就尽快改成strtok_r别等它线上炸了再救。3.3 Windows平台与跨平台兼容方案strtok_r是 POSIX 标准函数Linux、macOS、各种 BSD 系统上的 C 库都提供了。但 Windows 的 MSVC 运行库里没有strtok_r它提供了一个行为几乎一样的函数叫strtok_s。MSVC 版本的声明如下char *strtok_s( char *strToken, const char *strDelimit, char **context );参数顺序和strtok_r完全一致用法也一致。因此做跨平台代码时可以用一层宏把名字统一起来。一个比较常用的写法是#if defined(_WIN32) !defined(__MINGW32__) #define strtok_r strtok_s #endif然后代码里就统一使用strtok_r。MINGW 环境本身提供 POSIX 版strtok_r所以宏要排除它。另一点写严格标准模式时比如 GCC 用-stdc11并且没有开默认 GNU 扩展strtok_r可能不会被string.h自动声明。解决办法是在所有头文件之前定义特性测试宏#define _POSIX_C_SOURCE 200809L #include string.h这样就能保证 glibc 把符合 POSIX.1-2008 的声明暴露出来。如果你在用 C11 Annex K 标准里定义的strtok_s它有一个带缓冲区长度参数的完全不同的签名char *strtok_s(char *s1, rsize_t *s1max, const char *s2, char **ptr);实际工程项目里这个版本用得很少各个编译器支持也参差不齐我在文章里提到它只是为了让你看到同名函数时别认错。4. 不同场景怎么选strtok、strtok_r、strsep 快速对照4.1 功能差异与选型对照表除了strtok和strtok_r另外还有一个容易混淆的strsep。三者放在一起对比才最容易看出差异。对比项strtokstrtok_rstrsep线程安全不安全安全安全支持嵌套切分不支持支持支持连续分隔符处理合并/忽略合并/忽略返回空 token开头分隔符处理跳过跳过产生空 token是否修改原字符串是是是标准归属C89/C99POSIX.1-2001POSIX 草案/BSD 扩展典型适用快速拆分、单线程脚本多线程、嵌套解析需要保留空字段的解析这个表格基本上就是我在实际选型时的判断依据。单线程、代码一次性、不需要保留空字段用什么其实都差不多但为了项目长期维护我更推荐从一开始就直接用带_r的版本。多线程和嵌套场景没有第二个选项直接用strtok_r。如果切的是 CSV 且有空字段那就该上strsep或者手写解析循环。4.2 场景一单线程快速切分日志行有一类场景是纯粹的单线程工具比如写个临时脚本把/etc/passwd里每行的冒号字段打印出来或者把一个静态日志文件里的|分隔字段拆出来做统计。这种需求用strtok也能做代码短行为也比较直接。但我会给你一个更省心的建议从一开始就写成strtok_r的形态。原因有两个。第一多花的那一个save变量几乎不增加复杂度。第二项目一旦从单线程工具演变成常驻服务代码里的strtok就是一颗定时炸弹。我在很多项目里都见过“原来只是个小工具后来复用到一个线程池里”的情况到时候改起来远不如一开始就写对省事。4.3 场景二需要保留空字段的CSV解析真实 CSV 数据总会有空字段比如alice,,25,engineer年龄字段为空。这种数据用strtok或strtok_r处理都会把空字段吞掉导致后面的列错位。这时候strsep就派上用场了#include stdio.h #include string.h #include stdlib.h int main(void) { char csv[] alice,,25,engineer; char *cursor csv; char *field; while ((field strsep(cursor, ,)) ! NULL) { printf([%s]\n, field); } return 0; }输出结果为[alice] [] [25] [engineer]因为strsep不会合并连续分隔符连续逗号之间会返回一个空字符串解析结果的列数与原始文本保持一致。需要注意strsep的用法和strtok不一样调用时传的第一个参数不是字符串本身而是指向当前游标指针的地址也就是char **。每次调用后cursor会自动前进到下一个字段的起始位置。不过strsep也不是万能灵药。它遇到引号包裹的字段、字段内包含逗号的情况同样无能为力。正规的 CSV 解析还得自己实现带引号感知的逻辑或者使用成熟的第三方库。简单场景先用strsep顶住别过度设计。4.4 场景三解析完还要保留原字符串内容前面反复强调这类函数会原地修改字符串所以当你需要保留原始字符串时唯一的办法是解析前先复制char *copy strdup(original); char *save NULL; char *tok strtok_r(copy, delimiters, save); // 解析 copy free(copy); // original 仍然完好有些设计还会故意利用“原地修改”这个特性达到省内存的效果。比如大文件逐行读入一个固定缓冲区解析完一行就直接覆盖下一行不需要为每个 token 单独分配内存这是这种函数在嵌入式环境仍有存在价值的原因。但在理解清楚副作用之前不要贸然依赖这种行为。5. 高频踩坑记录与排查技巧5.1 为什么解析结果会变少、或者“串行”这是我被问过最多的问题