当前位置: 首页 > news >正文

Linux之封装线程库和线程的互斥

Linux之封装线程库和线程的互斥与同步

  • 一.封装线程库
  • 二.线程的互斥
    • 2.1互斥量的概念
    • 2.2初始化和销毁互斥量
    • 2.3加锁和解锁
    • 2.4互斥量的原理
    • 2.5可重入和线程安全
    • 2.6死锁

一.封装线程库

其实在我们C++内部也有一个线程库而C++中的线程库也是封装的原生线程库的函数,所以我们也可以自己来封装一个自己的线程库。

//mythread.hpp
#pragma once #include <iostream>
#include <pthread.h>
#include <string>
#include <functional>
#include <unistd.h>template<class T>
using func_t = std::function<void(T)>;template<class T>
class Thread
{
public:Thread(const std::string &ThreadName,func_t<T> func,T date):_ThreadName(ThreadName),_func(func),_date(date),_tid(0),_isrunning(false){}~Thread(){}//注意:这是类内成员函数,所以它不止是有void* args这个参数//还有一个this指针当作参数,所以就会造成pthread_create调用出错//因为pthread_create函数第三个参数是一个返回值为void*参数为void*的函数指针//所以想要解决这个问题我们可以利用static关键字将其变为全局函数//并且再将this指针当作参数传给它,这样也就可以解决全局函数无法访问私有成员变量的问题static void* ThreadRoutine(void* args){Thread* ts = static_cast<Thread*>(args);ts->_func(ts->_date);return nullptr;}bool Start(){int n = pthread_create(&_tid,nullptr,ThreadRoutine,this);if(n == 0){_isrunning = true;return true;}        else{return false;}}bool Join(){if(!_isrunning){return true;}int n = pthread_join(_tid,nullptr);if(n == 0){_isrunning = false;return true;}return false;}std::string Theadname(){return _ThreadName;}bool IsRunning(){return _isrunning;}private:pthread_t _tid;std::string _ThreadName;bool _isrunning;func_t<T> _func;T _date;
};
//mythread.cc
#include "mythread.hpp"std::string GetThreadName()
{static int number = 1;char name[64];snprintf(name,sizeof(name),"Thread-%d",number++);return name;
}void Print(int num)
{while(num){std::cout << "i am a new thread"<< num-- << std::endl;sleep(1);}
}int main()
{Thread<int> t(GetThreadName(),Print,10);t.Start();t.Join();return 0;
}

在这里插入图片描述

二.线程的互斥

2.1互斥量的概念

在聊线程的互斥之前我们可以通过一个实验来观察到我们多线程代码的一个问题

// mythread.cc
#include "mythread.hpp"std::string GetThreadName()
{static int number = 1;char name[64];snprintf(name, sizeof(name), "Thread-%d", number++);return name;
}void Print(int num)
{while (num){std::cout << "i am a new thread" << num-- << std::endl;sleep(1);}
}class ThreadDate
{
public:ThreadDate(const std::string threadname): _threadname(threadname){}std::string _threadname;
};//抢票程序
int ticket = 10000;
int GetTicket(ThreadDate* td)
{while (true){if (ticket > 0){//充当抢票的时间usleep(1000);std::cout << td->_threadname.c_str() << " get a ticket:" << ticket << std::endl;ticket--;}else{break;}}
}int main()
{std::string name1 = GetThreadName();ThreadDate* td1 = new ThreadDate(name1);Thread<ThreadDate*> t1(name1,GetTicket,td1);std::string name2 = GetThreadName();ThreadDate* td2 = new ThreadDate(name2);Thread<ThreadDate*> t2(name2,GetTicket,td2);std::string name3 = GetThreadName();ThreadDate* td3 = new ThreadDate(name3);Thread<ThreadDate*> t3(name3,GetTicket,td3);std::string name4 = GetThreadName();ThreadDate* td4 = new ThreadDate(name4);Thread<ThreadDate*> t4(name4,GetTicket,td4);t1.Start();t2.Start();t3.Start();t4.Start();t1.Join();t2.Join();t3.Join();t4.Join();delete td1;delete td2;delete td3;delete td4;return 0;
}

正常来说我们运行这个抢票程序最后打印的应该是抢到1号票,后面就不会再继续抢票了那么事实真的像我们想的这样吗?
在这里插入图片描述
在这里插入图片描述
我们发现并不像我们所说的那样,线程居然会抢到0号,-1号,-2号票,这根本不符合我们的代码逻辑啊这是为什么呢?

这就要提到我们之前学习进程间通信的知识了
在学习进程间通信的时候我们提到过公共资源的概念而公共资源是需要保护的这是因为如果多个执行流去访问公共资源很容易产生数据不一致的情况并且数据不一致现象的出现本质上来说也是因为访问公共资源的程序不是原子性的所以公共资源是需要被保护的。那么被保护起来的公共资源就是我们说的临界资源而访问临界资源的代码就是临界区,保护的手段当时我们也提了一下分为互斥和同步。互斥简单来说就是让任何时刻只允许一个执行流去访问公共资源,而同步则是让多个执行流保持着一种顺序去访问公共资源。
那么我们可以思考一下了我们刚刚定义的全局变量ticket是否也算是一种公共资源呢?我们模拟的抢票程序不就是在访问公共资源吗?而我们并没有对ticket进行保护它不就会出现数据不一致的情况吗?
在知道了这种情况产生的原因后我们来简单阐述一下产生的过程
在这里插入图片描述
在Linux中我们不太好观察汇编语句我们可以用vs来观察一下。
在这里插入图片描述
我们老是说因为多线程访问公共资源的操作不是原子的从而导致数据不一致的情况产生但是从来没说这个情况到底是怎么产生的其中的过程到底是什么样的。接下来我结合图和文字来为大家解释。
在这里插入图片描述

首先我用文字来为大家描述一下数据不一致的整个过程。
假设有两个线程A和B并且此时ticket已经为1了,线程A先执行当它在进行第一步即读取ticket的值1传到自己的寄存器后,A的时间片到了CPU切换到了线程B而A就带着自己寄存器的内容即它的上下文去等待下一次的调度。现在线程B来执行这五步操作,执行的非常顺利它成功的抢到了最后一张票并将内存中ticket的减为了0。然后CPU切换回了线程A让其继续执行这五步所以线程A将自己的上下文特别是ticket的值1写入到了寄存器中,随后继续执行未完成的第二步即让CPU来判断寄存器中存储的ticket是否大于0,判断结果是大于0的。

注意:此时的线程A的判断结果其实是错误的,因为ticket的已经被线程B减到0了但是由于线程A在切换之前已经将ticket的值读取到了自己的寄存器中而CPU进行判断又是判断的是寄存器中的内容而不是此时内存中的内容。这就导致了线程A判断结果是大于0的。

在判断结果是大于0后线程A继续执行剩下的三步,先将内存中ticket的0读取到寄存器中再进行减一最后写入到内存中从而导致了线程A抢到了本不应该存在的第0号票!
而这只是两个线程并发访问的情况要知道我们刚刚可是创建了四个线程来抢票,所以更容易产生这样的问题即可能有三个线程都是ticket=1的时候读取了ticket的值到寄存器中随后在要进行第二步的时候被切换走了,之后第四个线程完成了–的操作让ticket为0。但是那三个线程在被切换回来后还是继续执行了剩下的四步导致抢到了第0号,第-1号,第-2号。

那么要如何解决这个问题呢?如果我们只允许一个线程进入临界区去访问临界资源不就不会产生这种问题了吗,这种解决办法用专业一点的术语就是加锁也就是利用互斥。而这把锁也就叫做互斥量。
那么接下来我们来介绍一下加锁的一些接口,先了解接口我们再使用并且理解。

2.2初始化和销毁互斥量

初始化互斥量有两种方法分为静态初始化互斥量和动态初始化互斥量

  1. 静态初始化互斥量
    在这里插入图片描述
    非常的简单,我们可以在任意地方静态初始化互斥量并且静态的互斥量不需要销毁
  2. 动态初始化和销毁互斥量
    在这里插入图片描述

动态初始化互斥量中第一个参数是一个互斥量变量第二个参数是互斥量的属性通常我们设为NULL即可。
销毁互斥量只需要传入一个互斥量变量即可。

注意:不要销毁一个被加锁的互斥量并且已经被销毁的互斥量之后要确保没有线程再对其进行加锁

2.3加锁和解锁


第一个函数就是加锁,第二个函数是检测是否能加锁,第三个函数是解锁
在加锁的时候可能会遇到两种情况:

  1. 这个互斥量没有被加锁,那么调用lock函数就会加锁成功并且返回0
  2. 这个互斥量已经被加锁或者和其他线程同时申请这个互斥量时没有竞争过其他线程,那么调用lock函数就会加锁失败并且会将次线程移入到阻塞队列中进行等待直到互斥量解锁。
    而如果不想让线程在申请加锁失败的情况下被阻塞的话就可以使用第二个函trylock,使用这个函数加锁失败时只会返回错误码。

那么我们来尝试使用一下互斥量吧
在这里插入图片描述

在这里插入图片描述

2.4互斥量的原理

可是就如同我们面对信号量时的问题又出来了,这么多的线程去访问互斥量那它不就变成一个公共资源了那它不也需要被保护吗?但是它肯定不能再去让别人保护它吧所以对于互斥量的保护只能从它自己入手也就是让它对加锁的操作变成原子的。这又是如何做到的呢?
在这里插入图片描述
我们也可以用图来解释
在这里插入图片描述
想要理解这部分的内容需要大家知道几个知识
寄存器在硬件中只有一份但是寄存器的内容是具有多份的这就导致每个线程都有一个属于自己的上下文。
而xchgb的作用就是将一个共享的mutex资源交换到一个线程的自己的上下文中从而让其属于线程自己进而完成加锁的工作!!!

2.5可重入和线程安全

在之前学习进程间通信的时候我们谈到过可重入函数的概念,如今我们学会了并发多线程访问公共资源就更需要关注可重入的问题了
我们先来复习一下可重入函数的概念
当同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
同时我们要注意:可与不可重入函数是不分褒贬不分好坏的,可不可以重入只是函数的一个特点而已!

在我们学习了线程后肯定要谈论到线程的安全问题
对于线程的不安全问题一般是有以下几个情况

  • 不保护共享变量的函数
  • 函数状态随着被调用,状态发生变化的函数
  • 返回指向静态变量指针的函数
  • 调用线程不安全函数的函数

那么线程的不安全问题和重入函数又有什么关系呢?
我们一般来说函数是可重入那么就是线程安全的,如果函数不可重入那就不可以让多线程去使用不然就会引发线程的安全问题。并且如果一个函数中全局变量那么整个函数既不是可重入的也不是线程安全的。

而线程安全又和可重入函数有什么区别呢?为什么要把这两个概念放在一起讲述
可重入函数是线程安全函数的一种
线程安全不一定是可重入的,但是可重入函数一定是线程安全的。
不可重入函数不一定是线程不安全的,线程不安全也不一定是不可重入函数。
线程安全与否是线程的一种特征,而函数是否可重入则是函数的一种特点。

2.6死锁

在我们了解了锁后我们现在来思考使用锁是否会带来一些问题呢?
在我们刚刚使用锁后很明显的问题的就是代码的执行速度变慢了这也很正常因为本来是多线程去访问临界资源的使用锁后变成了只能一个一个的去访问临界资源,速度当然会变慢。

但是锁的问题不仅仅出现在这里我们来假设一个场景
在这个场景中有两个线程有两把锁,A线程拥有A锁B线程拥有B锁但是于此同时呢A线程又去申请B锁B线程又去申请A锁这件造成这两个线程全部都申请失败并且进入阻塞状态从而导致整个临界区卡住了,因为两个执行流全部都阻塞住了他们俩都不愿意放下自己的锁并且还想申请别的锁。这种问题就是死锁问题。
在这里插入图片描述
所以死锁是指在一组进程中的各个线程均占有不会释放的资源,但因互相申请被其他线程所占用不会释放的资源而处于的一种永久等待状态。
我们上面演示的只是死锁的一种出现情况而已其实对于死锁来说有着四个必要条件:

  • 互斥条件:一个资源每次只能被一个执行流使用
  • 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
  • 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
  • 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系

这四个必要条件我们可以继续用上面的例子来进行解释:
只要你用了锁就满足了互斥条件。
像A线程那样有了A锁又去申请B锁同时还不解开A锁就满足了请求与保持条件。
A线程去申请B锁就必须在B解开锁后才能申请成功,没法直接强取豪夺了就满足了不剥夺条件。
A线程申请B锁B线程申请A锁导致A线程在等待B线程解开B锁而B线程又在等待A线程解开A锁从而满足循环等待条件。

那么想要避免死锁问题就只要破坏上面四个必要条件中的一个或者多个即可
如破坏互斥条件就只需要不用锁就行,破坏请求与保持条件就让一个线程在申请锁资源前把自己拥有的锁全部解开,破坏不剥夺条件也是类似请求与保持条件,破坏循环等待条件就是让一个线程按照顺序去申请全部的锁资源就可以。这也就是为什么多线程代码的编写难度要难的一个原因。

相关文章:

Linux之封装线程库和线程的互斥

Linux之封装线程库和线程的互斥与同步 一.封装线程库二.线程的互斥2.1互斥量的概念2.2初始化和销毁互斥量2.3加锁和解锁2.4互斥量的原理2.5可重入和线程安全2.6死锁 一.封装线程库 其实在我们C内部也有一个线程库而C中的线程库也是封装的原生线程库的函数&#xff0c;所以我们…...

PH热榜 | 2024-12-08

1. Reforged Labs 标语&#xff1a;轻松为手游工作室制作AI广告。 介绍&#xff1a;Reforged Labs 推出了一款前所未有的AI视频制作服务。我们自动化了以往昂贵且耗时的创意流程&#xff0c;取而代之的是能快速、低成本地为各个工作室量身定制视频广告。 产品网站&#xff1…...

LeetCode刷题day20——贪心

LeetCode刷题day20——贪心 435. 无重叠区间763. 划分字母区间分析&#xff1a; 56. 合并区间分析&#xff1a; 435. 无重叠区间 给定一个区间的集合 intervals &#xff0c;其中 intervals[i] [starti, endi] 。返回 需要移除区间的最小数量&#xff0c;使剩余区间互不重叠 …...

CCF编程能力等级认证GESP—C++3级—20241207

CCF编程能力等级认证GESP—C3级—20241207 单选题&#xff08;每题 2 分&#xff0c;共 30 分&#xff09;判断题&#xff08;每题 2 分&#xff0c;共 20 分&#xff09;编程题 (每题 25 分&#xff0c;共 50 分)数字替换打印数字 单选题&#xff08;每题 2 分&#xff0c;共 …...

Microi 吾码:大数据浪潮中的智能领航者

目录 一、大数据时代的挑战与机遇 二、Microi 吾码在大数据存储方面的应用 与分布式文件系统的集成 数据库存储优化 三、Microi 吾码在大数据处理与分析中的应用 数据清洗与转换 数据分析与挖掘 四、Microi 吾码在大数据可视化中的应用 五、Microi 吾码在大数据流式处…...

Lua语言入门 - Lua 数组

Lua 数组 数组&#xff0c;就是相同数据类型的元素按一定顺序排列的集合&#xff0c;可以是一维数组和多维数组。 在 Lua 中&#xff0c;数组不是一种特定的数据类型&#xff0c;而是一种用来存储一组值的数据结构。 实际上&#xff0c;Lua 中并没有专门的数组类型&#xff…...

gulp应该怎么用,前端批量自动化替换文件

背景 最近公司准备把所有项目中用到的国际化相关的key规范化&#xff0c;原因是: 一直以来公司的app和web端 在针对相同的需求以及相同的国际化语言&#xff0c;需要设置不同的两份国际化文件&#xff0c;难以维护旧版的国际化文件中&#xff0c;存在的大量值重复&#xff0c…...

石岩湿地公园的停车场收费情况

周末石岩湿地公园停车场【967个】小车停车费封顶14元价格还行&#xff0c;我还记得2020年的时候湿地公园还是10元一天封顶。现在的收费情况也是可以的&#xff0c;尤其是周末停车比工作日停车便宜还是很得民心的哈。 车型 收费标准 小车 工作日 高峰时间8:00~20:00 首小时…...

A7157 基于Java+SSM+mysql+jsp的医院挂号系统的设计与实现 源码 文档 配置 全套资料

医院挂号系统 1.项目描述2. 绪论3.项目功能4.界面展示5.源码获取 1.项目描述 摘 要 随着计算机和网络技术的飞速发展&#xff0c;医院管理与互联网的结合也越来越紧密&#xff0c;享受便捷的医疗服务也变成了人民群众关注的重点。通过对医院就诊挂号情况的调查分析&#xff0c…...

数据处理与统计分析——11-Pandas-Seaborn可视化

Seaborn 简介 Seaborn 是一个基于 Matplotlib 的图形可视化 Python 库&#xff0c;提供了高度交互式的接口&#xff0c;使用户能够轻松绘制各种吸引人的统计图表。Seaborn 可以直接使用 Pandas 的 DataFrame 和 Series 数据进行绘图。 1. Seaborn 绘制单变量图 (1) 直方图 h…...

【计算机网络】实验13:运输层端口

实验13 运输层端口 一、实验目的 本次实验旨在验证TCP和IP运输层端口号的作用&#xff0c;深入理解它们在网络通信中的重要性。通过实验&#xff0c;我将探讨端口号如何帮助区分不同的应用程序和服务&#xff0c;使得在同一台主机上能够同时运行多个网络服务而不发生冲突。此…...

STL之适配器(adapters)_下

STL之适配器adapters container adapersstackqueue iterator adaptgersinsert iteratorsreverse iteratorsstream iterators function adapters对返回值进行逻辑判断:not1,not2对参数进行绑定:bind1st, bind2nd用于函数合成&#xff1a;compose1,compose2用于函数指针 ptr_func…...

基于51单片机64位病床呼叫系统设计( proteus仿真+程序+设计报告+原理图+讲解视频)

基于51单片机病床呼叫系统设计( proteus仿真程序设计报告原理图讲解视频&#xff09; 仿真图proteus7.8及以上 程序编译器&#xff1a;keil 4/keil 5 编程语言&#xff1a;C语言 设计编号&#xff1a;S0095 1. 主要功能&#xff1a; 基于51单片机的病床呼叫系统proteus仿…...

安装 Zookeeper 和 Kafka

注意&#xff1a;需要java环境 [roothcss-ecs-2a6a ~]# java -version java version "17.0.12" 2024-07-16 LTS Java(TM) SE Runtime Environment (build 17.0.128-LTS-286) Java HotSpot(TM) 64-Bit Server VM (build 17.0.128-LTS-286, mixed mode, sharing) [roo…...

操作系统输入输出系统知识点

I/O系统的功能、模型和接口 I/O系统的基本功能 隐藏物理设备的细节与设备的无关性提高处理机和I/O设备的利用率对1/0 设备进行控制确保对设备的正确共享 独占设备&#xff0c;进程应互斥地访问这些设备共享设备&#xff0c;在一段时间内允许多个进程同时访问的设备 错误处理 I…...

C语言控制语句与案例

控制语句与案例 1. 选择结构 1.1 if 语句 if 语句用于根据条件执行不同的代码块。最基本的语法形式如下&#xff1a; // 单分支 if (条件) {// 条件为真时执行的代码 }// 双分支 if (条件) {// 条件为真时执行的代码 } else {// 条件为假时执行的代码 }// 多分支 if (条件1…...

JVM的内存布局

Java虚拟机&#xff08;JVM&#xff09;的内存布局可以分为几个主要部分&#xff0c;每个部分都有特定的用途。以下是JVM内存布局的基本组成&#xff1a; 方法区&#xff08;Method Area&#xff09;&#xff1a; 方法区是所有线程共享的内存区域&#xff0c;用于存储已被虚拟机…...

aws codepipeline + github + sonarqube + jenkins实践CI/CD

https://blog.csdn.net/u011564831/article/details/144007981文章浏览阅读1.2k次&#xff0c;点赞31次&#xff0c;收藏21次。本文使用 Jenkins 结合 CodeBuild, CodeDeploy 实现 Serverless 的 CI/CD 工作流&#xff0c;用于自动化发布已经部署 lambda 函数。在 AWS 海外区&a…...

mistralai 部署笔记

目录 mistralai 部署笔记 mistralai 部署笔记 #! /usr/bin/env python3 import os import sys import torch os.chdir(os.path.dirname(os.path.abspath(__file__)))current_dir = os.path.dirname(os.path.abspath(__file__))paths = [os.path.abspath(__file__).split(scri…...

Java——异常机制(上)

1 异常机制本质 (异常在Java里面是对象) (抛出异常&#xff1a;执行一个方法时&#xff0c;如果发生异常&#xff0c;则这个方法生成代表该异常的一个对象&#xff0c;停止当前执行路径&#xff0c;并把异常对象提交给JRE) 工作中&#xff0c;程序遇到的情况不可能完美。比如…...

Android Wi-Fi 连接失败日志分析

1. Android wifi 关键日志总结 (1) Wi-Fi 断开 (CTRL-EVENT-DISCONNECTED reason3) 日志相关部分&#xff1a; 06-05 10:48:40.987 943 943 I wpa_supplicant: wlan0: CTRL-EVENT-DISCONNECTED bssid44:9b:c1:57:a8:90 reason3 locally_generated1解析&#xff1a; CTR…...

React 第五十五节 Router 中 useAsyncError的使用详解

前言 useAsyncError 是 React Router v6.4 引入的一个钩子&#xff0c;用于处理异步操作&#xff08;如数据加载&#xff09;中的错误。下面我将详细解释其用途并提供代码示例。 一、useAsyncError 用途 处理异步错误&#xff1a;捕获在 loader 或 action 中发生的异步错误替…...

解锁数据库简洁之道:FastAPI与SQLModel实战指南

在构建现代Web应用程序时&#xff0c;与数据库的交互无疑是核心环节。虽然传统的数据库操作方式&#xff08;如直接编写SQL语句与psycopg2交互&#xff09;赋予了我们精细的控制权&#xff0c;但在面对日益复杂的业务逻辑和快速迭代的需求时&#xff0c;这种方式的开发效率和可…...

定时器任务——若依源码分析

分析util包下面的工具类schedule utils&#xff1a; ScheduleUtils 是若依中用于与 Quartz 框架交互的工具类&#xff0c;封装了定时任务的 创建、更新、暂停、删除等核心逻辑。 createScheduleJob createScheduleJob 用于将任务注册到 Quartz&#xff0c;先构建任务的 JobD…...

在 Nginx Stream 层“改写”MQTT ngx_stream_mqtt_filter_module

1、为什么要修改 CONNECT 报文&#xff1f; 多租户隔离&#xff1a;自动为接入设备追加租户前缀&#xff0c;后端按 ClientID 拆分队列。零代码鉴权&#xff1a;将入站用户名替换为 OAuth Access-Token&#xff0c;后端 Broker 统一校验。灰度发布&#xff1a;根据 IP/地理位写…...

Python实现prophet 理论及参数优化

文章目录 Prophet理论及模型参数介绍Python代码完整实现prophet 添加外部数据进行模型优化 之前初步学习prophet的时候&#xff0c;写过一篇简单实现&#xff0c;后期随着对该模型的深入研究&#xff0c;本次记录涉及到prophet 的公式以及参数调优&#xff0c;从公式可以更直观…...

反射获取方法和属性

Java反射获取方法 在Java中&#xff0c;反射&#xff08;Reflection&#xff09;是一种强大的机制&#xff0c;允许程序在运行时访问和操作类的内部属性和方法。通过反射&#xff0c;可以动态地创建对象、调用方法、改变属性值&#xff0c;这在很多Java框架中如Spring和Hiberna…...

Go语言多线程问题

打印零与奇偶数&#xff08;leetcode 1116&#xff09; 方法1&#xff1a;使用互斥锁和条件变量 package mainimport ("fmt""sync" )type ZeroEvenOdd struct {n intzeroMutex sync.MutexevenMutex sync.MutexoddMutex sync.Mutexcurrent int…...

并发编程 - go版

1.并发编程基础概念 进程和线程 A. 进程是程序在操作系统中的一次执行过程&#xff0c;系统进行资源分配和调度的一个独立单位。B. 线程是进程的一个执行实体,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。C.一个进程可以创建和撤销多个线程;同一个进程中…...

R 语言科研绘图第 55 期 --- 网络图-聚类

在发表科研论文的过程中&#xff0c;科研绘图是必不可少的&#xff0c;一张好看的图形会是文章很大的加分项。 为了便于使用&#xff0c;本系列文章介绍的所有绘图都已收录到了 sciRplot 项目中&#xff0c;获取方式&#xff1a; R 语言科研绘图模板 --- sciRplothttps://mp.…...