跳到主要内容
Linux 线程同步与互斥详解(含 C++ 代码示例) | 极客日志
C++
Linux 线程同步与互斥详解(含 C++ 代码示例) 综述由AI生成 详细讲解了 Linux 环境下线程同步与互斥的核心概念。内容包括互斥锁的定义、实现原理及防止数据竞争的方法,分析了死锁产生的必要条件与避免策略。重点介绍了条件变量在解决线程饥饿问题中的应用,并通过生产者 - 消费者模型演示了如何结合互斥锁与条件变量实现阻塞队列。此外还涵盖了信号量的基本用法。文中提供了完整的 C++ 代码示例,包括锁的封装、任务对象化及多线程协作逻辑,旨在帮助开发者掌握 Linux 多线程编程的关键技术。
ByteFlow 发布于 2026/2/8 更新于 2026/5/26 2K 浏览Linux 线程同步与互斥
1. 线程的互斥
背景
在程序中部分资源是共享的,如全局变量所有线程都能访问。当多个线程同时访问这种共享资源时,就可能导致数据不一致。
解决方法 :
任何一个时刻,只允许一个线程正在访问共享资源——临界资源(即共享的数据)。
我们把访问进程中访问临界资源的代码称为临界区(被保护的区域)。
概念 :
互斥 :任何时刻,互斥保证有且只有一个执行流进入临界区。
原子性 :不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。
1.1 互斥的案例
对于 C/C++ 代码会经过编译变成汇编,而某些代码的底层可能不止一条汇编语句,肯定不是原子的。例如变量 a++ 的底层过程:
因为时间片切换,可能在一个代码底层的多个语句中的第二个语句时间片就到了,导致未执行完就切走了。轮到执行其他线程,假设其他线程也同样访问相同的内存资源,并修改了该资源。当后面时间片到了,又轮到一开始的线程,他会继续从未完成的部分开始,而非先读取内存数据,这样就会导致前面的线程的任务白做 (因为一开始的线程的数据是之前的数据,他修改后就会导致数据回到之前)!
多线程并发访问全部整形的汇编,若不是原子的,就会有数据不一致的并发问题。
认识到 CPU 中执行过程:
算:算术运算
逻:逻辑运算(真假)
中:处理内外中断
控:控制单元(时钟控制)
其中 if 判断语句同样不是原子的 (本质也和上面的 ++ 一样需要到 CPU 中去处理判断,这样汇编语句就有多个)。数据在内存中,本质是被线程共享的,数据被读取到寄存器汇总,本质变成了线程的上下文,它是属于线程私有数据。
1.2 互斥锁函数
定义锁 :
全局锁:pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER,此时定义的全局锁就能直接使用,并且不用销毁。
初始化锁:int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr),attr 是锁的属性,暂不考虑写成 nullptr。
销毁锁 :
int pthread_mutex_destroy(pthread_mutex_t *mutex)。
当在局部创建一把锁时,需要对其进行初始化操作:pthread_mutex_init、pthread_mutex_destroy,也就代表使用完后需要摧毁。因为自定义的函数会使用到锁所以需要把它当参数传递进去。
加锁 :pthread_mutex_lock(pthread_mutex_t* mutex)
解锁 :pthread_mutex_unlock(pthread_mutex_t* mutex)
申请成功返回 0,失败返回错误码。
注意事项 :
加锁时我们要尽可能的少给代码块加锁。
一般加锁是给临界区加锁。
申请锁本身是安全、原子的。
一旦有了公共资源,程序员就需要保证是加锁的! 根据互斥的定义:任何时刻只允许一个线程申请锁成功!其他线程申请失败时,会在 mutex 锁上进行阻塞,本质也就是等待。
推出了不阻塞的锁 :
5. 不阻塞的申请锁:pthread_mutex_trylock(pthread_mutex_t* mutex)
线程在临界区中访问加锁的临界资源的时候是可能发生切换,但即便切换了别人也仍然不能访问。(理解成,一个房间只要有钥匙才能进入,而当我们进入后我们出去时是拿着钥匙走的,别人同样还是进不去)
1.3 简单互斥锁(源码)
#include <iostream>
#include <thread>
#include <cstdlib>
#include "LockGuard.hpp"
#include "Thread.hpp"
#include <unistd.h>
#include <vector>
#include <string>
using namespace std;
class ThreadData {
public :
ThreadData (string& name, pthread_mutex_t * lock) : threadname (name), pmutex (lock) {}
public :
string threadname;
pthread_mutex_t * pmutex;
};
void Print (int num) {
while (num) {
std::cout << "hello world: " << num-- << std::endl;
sleep (1 );
}
}
int ticket = 10000 ;
void GetTicket (ThreadData* td)
{
while (true ) {
{
LockGuard lockguard (td->pmutex) ;
if (ticket > 0 )
{
usleep (1000 );
printf ("%s get a ticket: %d\n" , td->threadname.c_str (), ticket);
ticket--;
}
else {
break ;
}
}
}
}
string GetThreadName () {
static int number = 1 ;
char name[64 ];
snprintf (name, sizeof (name), "Thread-%d" , number++);
return name;
}
int main () {
pthread_mutex_t mutex;
pthread_mutex_init (&mutex, nullptr );
string name1 = GetThreadName ();
ThreadData* td1 = new ThreadData (name1, &mutex);
Thread<ThreadData*> t1 (td1, GetTicket, name1) ;
string name2 = GetThreadName ();
ThreadData* td2 = new ThreadData (name2, &mutex);
Thread<ThreadData*> t2 (td2, GetTicket, name2) ;
string name3 = GetThreadName ();
ThreadData* td3 = new ThreadData (name3, &mutex);
Thread<ThreadData*> t3 (td3, GetTicket, name3) ;
string name4 = GetThreadName ();
ThreadData* td4 = new ThreadData (name4, &mutex);
Thread<ThreadData*> t4 (td4, GetTicket, name4) ;
t1. Start ();
t2. Start ();
t3. Start ();
t4. Start ();
t1. Join ();
t2. Join ();
t3. Join ();
t4. Join ();
pthread_mutex_destroy (&mutex);
delete td1;
delete td2;
delete td3;
delete td4;
return 0 ;
}
#pragma once
#include <iostream>
#include <string>
#include <functional>
#include <pthread.h>
using namespace std;
template <class T >
using func_t = function<void (T)>;
template <class T >
class Thread {
public :
Thread (T data, func_t <T> func, const string& name) : _tid(0 ), _name(name), _isrunning(false ), _func(func), _data(data) {}
static void * ThreadRoutine (void * args) {
Thread* ts = static_cast <Thread*>(args);
ts->_func(ts->_data);
return nullptr ;
}
bool Start () {
int n = pthread_create (&_tid, nullptr , ThreadRoutine, this );
if (n == 0 ) {
_isrunning = true ;
return true ;
} else
return false ;
}
string Threadname () { return _name; }
bool Join () {
if (!_isrunning) return true ;
int n = pthread_join (_tid, nullptr );
if (n == 0 ) {
_isrunning = false ;
return true ;
}
return false ;
}
bool Isrunning () { return _isrunning; }
~Thread () {}
private :
string _name;
func_t <T> _func;
pthread_t _tid;
bool _isrunning;
T _data;
};
1.3. 附加:封装锁
把锁封装成一个类实现,当构造时加锁,析构时释放。
这样把该类写在一个代码块中当成局部变量,这样就能让其在代码块中进行加锁,出代码块析构解锁。
#pragma once
#include <pthread.h>
class Mutex {
public :
Mutex (pthread_mutex_t * lock) : _lock(lock) {}
void Lock () { pthread_mutex_lock (_lock); }
void Unlock () { pthread_mutex_unlock (_lock); }
~Mutex () {}
private :
pthread_mutex_t * _lock;
};
class LockGuard {
public :
LockGuard (pthread_mutex_t * lock) : _mutex(lock) { _mutex.Lock (); }
~LockGuard () { _mutex.Unlock (); }
private :
Mutex _mutex;
};
上述代码就能实现一个基本的抢票机制,通过互斥锁就不会导致数据错乱。
但有可能一个票被一个人全部都抢了,对其他线程就形成了饥饿问题,他的原理是一个线程在申请完锁,解锁后又立马申请锁资源。
要解决饥饿问题,互斥是无法解决的,也就是同步解决:当一个线程申请完锁后不能再立马申请锁资(也就是有一定的顺序性),请看目录中的同步。
1.4 互斥锁的本质 大多数体系结构都提供了 swap/exchange 指令,该指令的作用是把寄存器和内存单元的数据相交换。
exchange eax mem_addr(也就是把寄存器 eax 的内容和内存数据 mem_addr 交换的汇编语句),因为只有一条主要语句所以交换的过程是原子的,其他语句执行时即使被切换,也会把自身数据(%al 寄存器的数据)带走,也就不会影响其他线程。
每个线程要执行加锁时首先都会先 move 0 到 %al 寄存器中,然后在和 mutex 的数据进行交换,然后判断寄存器 %al 中的值>0 则表示加锁成功,反之则加锁失败在加锁处挂起等待!
加锁失败的情况,因为有人已经在使用该锁,所以会把内存中的 mutex 改为了 0(他也会执行第一个 move 操作),当他没有归还锁资源时,mutex 中的值为 0,当别人一交换则 %al 寄存器中就会变成 0 就会挂起等待了。(这样也就实现了互斥锁,加锁的区域只能由一个线程使用!)
寄存器和内存数据的交换(因为只有一条语句所以交换的过程是原子的,xchgb %al mutex)
解锁的原理就是基于加锁的,把内存数据 mutex 改回 1,并唤醒等待挂起的线程即可。
加锁原则:谁加锁,谁解锁
1.5 认识:可重入 与 线程安全
多个线程并发同一段代码时,不会出现不同的结果。
若出现问题(如:崩溃,数据异常),则是线程不安全的。
其中线程安全和可重入本质是一样的,只不过可重入是函数的概念,线程安全则是线程的概念。
不保护共享变量的函数(全局变量)
函数状态随着被调用,状态发生变化的函数(静态变量在函数中,每当被调用就会发生改变)
返回指向静态变量指针的函数
调用线程不安全函数的函数
每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的(可以理解成这个变量即使线程修改了,当线程结束后该值又会变回来相当于没变)
类或者接口对于线程来说都是原子操作
多个线程之间的切换不会导致该接口的执行结果存在二义性
调用了 malloc/free 函数,因为 malloc 函数是用全局链表来管理堆的
调用了标准 I/O 库函数,标准 I/O 库的很多实现都以不可重入的方式使用全局数据结构
可重入函数体内使用了静态的数据结构
不使用全局变量或静态变量
不使用用 malloc 或者 new 开辟出的空间
不调用不可重入函数
不返回静态或全局数据,所有数据都有函数的调用者提供
使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
可重入函数一定是线程安全的。
函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题。
如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
可重入函数是线程安全函数的一种。
线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。
1.6 死锁问题 原理 :
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用不会释放的资源而处于的一种永久等待状态。
可以理解成,现在用了两把锁 A/B 各用了一把锁,但两个线程必须同时持有两把锁才能继续往后运行(如下图)。
一个线程也可能死锁:在程序内部重复申请已经申请过的锁(此时申请不到就会被永久的挂起了)。
1.6.1 死锁产生的必要条件:
互斥条件 :一个资源每次只能被一个执行流使用。
请求与保持条件 :一个执行流因请求资源而阻塞时,对已获得的资源保持不放(相当于上面两个线程的情况)。
不剥夺条件 :一个执行流已获得的资源,在末使用完之前,不能强行剥夺(没有因为一个线程优先级高而强行使用所要的锁)。
循环等待条件 :若干执行流之间形成一种头尾相接的循环等待资源的关系(可见上图)。
1.6.2 避免死锁:
不加锁 (破坏互斥条件,但有些时候难以实现)。
若申请某个锁不成功后,释放自身的锁已有的锁 (那么就破坏了请求与保持条件)。
当申请某个锁时发现他是被占用的时,直接把他解锁再加锁到当前自己线程 (破坏不剥夺条件)。
尽量的把锁资源按顺序申请给线程! (破坏循环等待条件)。
避免锁未释放的场景以及资源一次分配(一个资源配一把锁)。
2. 线程的同步 背景 :
当一个线程短时间的不断的申请锁,释放锁,导致其他人长时间得不到资源,也就对其他线程产生饥饿问题,为了解决饥饿问题,归还资源后线程不能立即再次申请,再通过类似队列的结构(先进先出,也就是有顺序性)管理要申请锁资源的线程 。
同步 :在临界资源使用安全的前提下,让多线程执行具有一定的顺序性。互斥能保证资源的安全,同步能够较为充分高效的使用资源。
2.1 同步 + 互斥 -> CP 生产者消费者模型 日常生活中超市就是典型的生产者消费者模型就有生产者、消费者(如下图):
生产者 与生产者 的关系:互斥 (供应商相互竞争)。
消费者 与消费者 的关系:互斥 (假如只有一份商品了而都想要这个商品那么就是竞争关系)。
生产者 和消费者 的关系:互斥 (生产者放完了商品消费者才能拿商品)、同步 (不能让一方持续的处理商品(不断地买/卖)都是有问题的)。
3. 三种关系
2. 两种角色(生产线程/消费进程)
一个交易场所(内存空间)
2.2.1 生产者消费者模型的优势:
让多执行流之间的一个执行解耦 (把生产者和消费者两个线程,双方在内存空间内写或者拿数据,相当于把内存空间当成一个缓冲区可以存一部分数据,所以生产消费不需要相互等待)。
提高处理数据的效率 (通过代码解释)。
2.2 条件变量
条件变量是类似于铃铛提醒人的工具,这个条件变量是为了避免消费者(线程)在资源还没准备好就不断的去内存申请但也拿不到资源的情况(本质就是同步的工具),因为共享空间是互斥的,这样就会导致生产者也无法放数据(饥饿)。
其中消费者并不是在锁上等待资源,而是在条件变量处的一个队列(一个阻塞队列)。
先在条件变量处的队列中排队,当资源就绪条件变量就会唤醒消费线程。
当拿取后若想再拿就必须从后开始排(队列)。
struct cond {
int flag;
}()
当 flag 表示就绪就从线程队列中唤醒一个线程。
2.3 条件变量的函数
条件变量的定义 :
头文件:#include <pthread.h>
成功返回 0,错误返回错误码。
条件变量的使用必须要声明和摧毁。
int pthread_cond_destroy (pthread_cond_t *cond) ;
int pthread_cond_init (pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr) ;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
线程等待 (等待响铃,或者本质就是申请资源 ):
头文件:#include <pthread.h>
成功返回 0,错误返回错误码。
int pthread_cond_wait (pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex) ;
唤醒线程 (条件变量成立并):
头文件:#include <pthread.h>
成功返回 0,错误返回错误码。
int pthread_cond_broadcast (pthread_cond_t *cond) ;
int pthread_cond_signal (pthread_cond_t *cond) ;
2.3.1 条件变量的基本使用: #include <iostream>
#include <pthread.h>
#include <unistd.h>
using namespace std;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
void * threadRoutine (void * args) {
string name = static_cast <const char *>(args);
while (true ) {
pthread_mutex_lock (&mutex);
pthread_cond_wait (&cond, &mutex);
cout << "I am a new thread:" << name << endl;
pthread_mutex_unlock (&mutex);
}
}
int main () {
pthread_t t1, t2, t3;
pthread_create (&t1, nullptr , threadRoutine, (void *)"thread-1" );
pthread_create (&t2, nullptr , threadRoutine, (void *)"thread-2" );
pthread_create (&t3, nullptr , threadRoutine, (void *)"thread-3" );
sleep (5 );
while (true ) {
pthread_cond_signal (&cond);
sleep (1 );
}
pthread_join (t1, nullptr );
pthread_join (t2, nullptr );
pthread_join (t3, nullptr );
}
当只唤醒一个线程(pthread_cond_signal)时他是逐个的:
当唤醒全部线程 (pthread_cond_broadcast) 时他是所有线程一起的:
从上面的饥饿问题不能发现:单纯的互斥,能保证数据的安全,但不一定合理或高效。
当让线程在进行等待的时候,要自动释放申请的锁。
线程被在临界区内唤醒的时候,要重新申请并持有锁。
当多个线程被唤醒的时候 ,它们都要重新申请并持有锁,所以是要竞争锁的 。
调用该函数可能失败 ,这样就会让线程在不满足使用资源条件的前提下(队列中的资源不够多个线程分配)唤醒生产/消费线程,也称为:伪唤醒,也就是可能同时唤醒多个,但只有一个线程能拿到锁资源,其他没拿到锁资源的线程就是伪唤醒状态 。(对此解决方法是将 if 语句换成 while 语句这样就能)
2.4 实现:阻塞队列(及条件变量的应用)
互斥锁
条件变量
锁的封装
将任务对象化
阻塞队列(让线程实现顺序性)
模拟生产者消费者模型的运行!
生产者将一个任务 push 到队列中,而消费者再去通过 pop 得到数据并处理。
#pragma once
#include <iostream>
#include <queue>
#include <ctime>
#include <unistd.h>
#include <pthread.h>
#include "LockGuard.hpp"
using namespace std;
const int defaultcap = 5 ;
template <class T >
class Blockqueue {
public :
Blockqueue (int cap = defaultcap) : _capacity(cap) {
pthread_mutex_init (&_mutex, nullptr );
pthread_cond_init (&_c_cond, nullptr );
pthread_cond_init (&_p_cond, nullptr );
}
bool IsFull () { return _q.size () == _capacity; }
bool IsEmpty () { return _q.size () == 0 ; }
void Push (const T& in) {
LockGuard lockgaurd (&_mutex) ;
if (IsFull ()) {
pthread_cond_wait (&_p_cond, &_mutex);
}
_q.push (in);
pthread_cond_signal (&_c_cond);
}
void Pop (T* out) {
LockGuard lockgaurd (&_mutex) ;
if (IsEmpty ()) {
pthread_cond_wait (&_c_cond, &_mutex);
}
*out = _q.front ();
pthread_cond_signal (&_p_cond);
_q.pop ();
}
~Blockqueue () {
pthread_mutex_destroy (&_mutex);
pthread_cond_destroy (&_c_cond);
pthread_cond_destroy (&_p_cond);
}
private :
queue<T> _q;
size_t _capacity;
pthread_mutex_t _mutex;
pthread_cond_t _p_cond;
pthread_cond_t _c_cond;
};
#pragma once
#include <pthread.h>
class Mutex {
public :
Mutex (pthread_mutex_t * lock) : _lock(lock) {}
void Lock () { pthread_mutex_lock (_lock); }
void Unlock () { pthread_mutex_unlock (_lock); }
~Mutex () {}
private :
pthread_mutex_t * _lock;
};
class LockGuard {
public :
LockGuard (pthread_mutex_t * lock) : _mutex(lock) { _mutex.Lock (); }
~LockGuard () { _mutex.Unlock (); }
private :
Mutex _mutex;
};
#pragma once
#include <iostream>
enum { ok = 0 , div_zero, mod_zero, unknow };
class Task {
public :
Task () {}
Task (int x, int y, char op) : _x(x), _y(y), _oper(op) {}
void Run () {
switch (_oper) {
case '+' : result = _x + _y; break ;
case '-' : result = _x - _y; break ;
case '*' : result = _x * _y; break ;
case '/' : {
if (_y == 0 ) {
code = div_zero; break ;
}
result = _x / _y;
}
break ;
case '%' : {
if (_y == 0 ) {
code = mod_zero; break ;
}
result = _x % _y;
}
break ;
default : code = unknow; break ;
}
}
string PrintTask () {
string s;
s += to_string (_x);
s += _oper;
s += to_string (_y);
s += "=?" ;
return s;
}
void operator () () { Run (); }
string PrintResult () {
string s;
s += to_string (_x);
s += _oper;
s += to_string (_y);
s += " =" ;
s += to_string (result);
s += " [" ;
s += to_string (code);
s += "]" ;
return s;
}
~Task () {}
private :
int _x;
int _y;
char _oper;
int result;
int code;
};
#include <iostream>
#include <queue>
#include <ctime>
#include <unistd.h>
#include <pthread.h>
#include "LockGuard.hpp"
using namespace std;
const int defaultcap = 5 ;
template <class T >
class Blockqueue {
public :
Blockqueue (int cap = defaultcap) : _capacity(cap) {
pthread_mutex_init (&_mutex, nullptr );
pthread_cond_init (&_c_cond, nullptr );
pthread_cond_init (&_p_cond, nullptr );
}
bool IsFull () { return _q.size () == _capacity; }
bool IsEmpty () { return _q.size () == 0 ; }
void Push (const T& in) {
LockGuard lockgaurd (&_mutex) ;
while (IsFull ())
{
pthread_cond_wait (&_p_cond, &_mutex);
}
_q.push (in);
pthread_cond_signal (&_c_cond);
}
void Pop (T* out) {
LockGuard lockgaurd (&_mutex) ;
while (IsEmpty ())
{
pthread_cond_wait (&_c_cond, &_mutex);
}
*out = _q.front ();
pthread_cond_signal (&_p_cond);
_q.pop ();
}
~Blockqueue () {
pthread_mutex_destroy (&_mutex);
pthread_cond_destroy (&_c_cond);
pthread_cond_destroy (&_p_cond);
}
private :
queue<T> _q;
size_t _capacity;
pthread_mutex_t _mutex;
pthread_cond_t _p_cond;
pthread_cond_t _c_cond;
};
因为对于生产者消费者模型来说,我们不能只看内部他的生产和消费过程,这里是互斥的并没有效率的提升,但是从整体来看生产者线程获取数据的过程和消费者线程处理数据的过程也是需要时间的,他们在处理这些时间时,其他线程就能同步的去执行生产/消费过程,从而实现每个线程都能高效的执行其作用。
2.5 信号量 该方法同样也是实现同步的工具(只不过常用条件变量,所以这里就简略了)。
信号量的本质是一把计数器。
申请信号的本质就是预定资源。
把公共资源不当做整体,多线程不访问临界资源的同一个区域。
对此信号量为了防止分成的 n 份公共资源,分给了 n+k 个线程,信号量的作用就是:确定线程能否访问资源。
线程信号量申请成功后,当线程需要使用时就不需要再判断资源是否就绪,直接就能使用了(申请信号量时已经判断了)。
2.5.1 信号量的基本函数
信号量初始化与销毁
头文件:#include <semaphore.h>
int sem_init (sem_t *sem, int pshared, unsigned int value) ;
sem:返回定义的信号量(输出型)。
pshared:在线程间共享(设置为 0),还是进程间。
value:信号量初始的值。
int sem_destroy (sem_t *sem) ;
int sem_wait (sem_t *sem) ;
int sem_post (sem_t *sem) ;
上述函数的返回值都是:成功为 0,失败为非 0 错误码。
本章内容涵盖 Linux 下线程同步与互斥的核心机制,包括互斥锁原理、死锁避免、条件变量实现生产者 - 消费者模型及信号量基础。通过 C++ 代码示例展示了关键 API 的使用与封装技巧,帮助开发者构建安全高效的多线程程序。
相关免费在线工具 Base64 字符串编码/解码 将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
Base64 文件转换器 将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
Markdown转HTML 将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
HTML转Markdown 将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
JSON 压缩 通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
JSON美化和格式化 将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online