线程同步方式之二条件变量

news2024/11/24 7:16:11

Linux线程同步方法之二

条件变量

饥饿状态:由于线程A频繁地申请/释放锁,而导致其他线程无法访问临界资源的情况。

同步synchronized:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题

竞态条件Race Condition:当两个线程竞争同一资源时,如果对资源的访问顺序敏感,就称存在竞态条件。解决方案之一为信号量(semaphore)。

条件变量是利用线程间共享的全局变量进行同步的一种机制,主要包括两个动作:一个线程等待条件变量的条件成立而挂起;另一个线程使条件成立(给出条件成立信号)。为了防止竞争,条件变量的使用总是和一个互斥量结合在一起。

例如生产消费模型中一个线程访问队列时,发现队列为空,它只能等待,直到其它线程将一个节点添加到队列中,这种情况就需要用到条件变量。

生产消费模型

321原则==>是生产消费模型所需要维护的基本原则

  • 3种关系:生产者与生产者之间是互斥关系,消费者与消费者之间也是互斥关系,生产者和消费者之间既互斥又同步;
  • 2种角色:生产者线程、消费者线程;
  • 1个交易场所:一段特定结构的共享缓冲区。

生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。这个阻塞队列就是用来给生产者和消费者解耦的。

main()函数是生产者,它交给调用函数fun()的变量是数据,fun()函数是消费者,在这种情况,生产者和消费者处于强耦合状态(main要等待fun函数执行完毕返回)

优点

生产者和消费者之间解耦,支持并发,支持忙闲不均。

理解条件变量

当某种条件不满足的时候,线程必须去某些定义好的条件变量上进行等待。

条件变量内部有个PCB类型的队列,struct cond{ int status; task_struct* q; }; 当条件变量不满足的时候,就可以把该线程的PCB链接到后面进行等待。

条件变量函数

pthread_cond_init初始化和销毁

// 初始化
int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrictattr);
参数:
	cond:要初始化的条件变量
	attr:一般设为nullptr
// 销毁
int pthread_cond_destroy(pthread_cond_t *cond);

// ------作为全局变量初始化------ //
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

pthread_cond_wait等待条件满足

int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);
参数:
	cond:要在这个条件变量上等待
	mutex:互斥量

第二个参数必须传入互斥锁。是为了让函数在被调用的时候以原子性的方式释放锁,并将当前进程挂起。该函数在被唤醒返回的时候会自动重新获取锁,如果没有获取锁成功,就会一直竞争锁,直到成功,换而言之,只要该函数返回了就一定取得锁。

pthread_cond_signal唤醒等待

int pthread_cond_broadcast(pthread_cond_t *cond);//唤醒在cond条件下等待的所有线程
int pthread_cond_signal(pthread_cond_t *cond);//唤醒在cond条件下等待的单个进程

基于阻塞队列单/多生产消费模型的实现

日常使用中,基于互斥锁和条件变量访问临界资源的流程一般是1、先加锁;2、判断是否满足某种条件,满足则执行对应语句,不满足就阻塞挂起;3、然后再解锁。

在多线程编程中阻塞队列(Blocking Queue)是一种常用于实现生产者和消费者模型的数据结构。其与普通的队列区别在于,当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中被放入了元素;当队列满时,往队列里存放元素的操作也会被阻塞,直到有元素被从队列中取出(以上的操作都是基于不同的线程来说的,线程在对阻塞队列进程操作时会被阻塞)。

在这里插入图片描述

阻塞队列blockqueue就是这个程序里的共享资源,需要用锁进行互斥保护,用条件变量进行同步,生产者和消费者都需要在各自的cond下进行等待。

//BlockQueue.hpp
#pragma once

#include <iostream>
#include <string>
#include <queue>
#include <pthread.h>

const int g_maxcap = 5;

template <class T>
class BlockQueue
{
public:
    BlockQueue(const int &maxcap = g_maxcap) : _maxcap(g_maxcap)
    {
        pthread_mutex_init(&_mutex, nullptr);
        pthread_cond_init(&_pcond, nullptr);
        pthread_cond_init(&_ccond, nullptr);
    }

    // 生产者调用push
    // 输入输出型参数为&
    void push(const T &in) // 输入型参数一般为const T&
    {
        // 1 加锁
        pthread_mutex_lock(&_mutex);
        // 2.1 如果队列满了,就不能生产,生产者就要到条件变量下等
        while (isFull())
        {
            pthread_cond_wait(&_pcond, &_mutex);
        }
        // 2.2 走到这,队列一定没有满
        _q.push(in);
        // 3 此时阻塞队列里一定有数据,可以让消费者来消费
        pthread_cond_signal(&_ccond);
        // 4 解锁
        pthread_mutex_unlock(&_mutex);
    }

    // 消费者调用pop
    void pop(T *out) // 输出型参数一般为T*
    {
        // 1 加锁
        pthread_mutex_lock(&_mutex);
        // 2.1 如果队列是空的,就不能消费,消费者就要到条件变量下等
        while (isEmpty())
        {
            pthread_cond_wait(&_ccond, &_mutex);
        }
        // 2.2 走到这,队列一定有数据
        *out = _q.front();
        _q.pop();
        // 3 此时阻塞队列里一定有1个空位数据,可以让生产者放数据
        pthread_cond_signal(&_pcond);
        // 4 解锁
        pthread_mutex_unlock(&_mutex);
    }

    ~BlockQueue()
    {
        pthread_mutex_destroy(&_mutex);
        pthread_cond_destroy(&_ccond);
        pthread_cond_destroy(&_pcond);
    }

private:
    bool isEmpty()
    {
        return _q.empty();
    }
    bool isFull()
    {
        return _q.size() == _maxcap;
    }

private:
    std::queue<T> _q;
    int _maxcap;            // 最大容量
    pthread_mutex_t _mutex; // 互斥锁
    pthread_cond_t _pcond;  // 生产者的条件变量 -- 队列满
    pthread_cond_t _ccond;  // 消费者的条件变量 -- 队列空
};
//Task.hpp
#pragma once

#include <iostream>
#include <string>
#include <functional>

const std::string oper = "+-*/%";

int mymath(int x, int y, char op)
{
    int result = 0;
    switch (op)
    {
    case '+':
        result = x + y;
        break;
    case '-':
        result = x - y;
        break;
    case '*':
        return x * y;
        break;
    case '/':
    {
        if (y == 0)
        {
            std::cerr << "div zero error!" << std::endl;
            result = -1;
        }
        else
            result = x / y;
    }
    break;
    case '%':
    {
        if (y == 0)
        {
            std::cerr << "mod zero error!" << std::endl;
            result = -1;
        }
        else
            result = x % y;
    }
    break;
    default:
        // do nothing
        break;
    }
    return result;
}
class CalcTask
{
    // 以下两种写法等价
    // typedef std::function<int(int, int)> func_t; // c98写法
    using func_t = std::function<int(int, int, char)>; // C11写法
public:
    CalcTask()
    {}

    CalcTask(int x, int y, char op, func_t callback) :_x(x), _y(y), _op(op), _callback(callback)
    {}

    std::string operator()()
    {
        int result = _callback(_x, _y, _op);
        char buffer[1024];
        snprintf(buffer, sizeof buffer, "%d %c %d = %d", _x, _op, _y, result);
        return buffer;
    }
    std::string toTaskString()
    {
        char buffer[1024];
        snprintf(buffer, sizeof buffer, "%d %c %d = ?", _x, _op, _y);
        return buffer;
    }
private:
    int _x;
    int _y;
    char _op;
    func_t _callback;
};

void record(const std::string& message)
{
    const std::string target = "./log.txt";
    FILE* fp = fopen(target.c_str(), "a+");
    if(!fp)
    {
        std::cerr << "fopen error" << std::endl;
        return;
    }
    fputs(message.c_str(), fp);
    fputs("\n", fp);
    fclose(fp);
}
class RecoTask
{
    typedef std::function<void(const std::string&)> func_t;
public:
    RecoTask(){}
    RecoTask(const std::string& msg, func_t func) : _message(msg), _func(func)
    {}
    void operator()()
    {
        _func(_message);
    }
private:
    std::string _message;
    func_t _func;
};
//main_testcp.cc
#include "BlockQueue.hpp"
#include "Task.hpp"
#include <ctime>
#include <sys/types.h>
#include <unistd.h>

//两个队列,一个是计算队列,一个是存储队列
template <class Calc, class Reco>
class BlockQueues
{
public:
    BlockQueue<Calc> *c_bq;
    BlockQueue<Reco> *r_bq;
};

void *producer(void *args)
{
    BlockQueue<CalcTask> *bq = (static_cast<BlockQueues<CalcTask, RecoTask>*>(args))->c_bq;
    while (true)
    {
        // 1 生产随机数
        int x = rand() % 100 + 1;
        int y = rand() % 40;
        int operCode = rand() % oper.size();
        CalcTask t(x, y, oper[operCode], mymath);
        bq->push(t);
        std::cout << "producer thread " << t.toTaskString() << std::endl;
        sleep(1); // 让生产者慢一点
    }
    return nullptr;
}
void *consumer(void *args)
{
    BlockQueue<CalcTask> *bq = (static_cast<BlockQueues<CalcTask, RecoTask>*>(args))->c_bq;
    BlockQueue<RecoTask> *reco_bq = (static_cast<BlockQueues<CalcTask, RecoTask>*>(args))->r_bq;
    while (true)
    {
        CalcTask t;
        bq->pop(&t);
        std::string result = t();
        std::cout << "consumer thread " << t() << std::endl;

        RecoTask reco_t(result, record);
        reco_bq->push(reco_t);
        std::cout << "consumer thread 推送保存任务已完成" << std::endl;
    }
    return nullptr;    
}
void *recorder(void *args)
{
    BlockQueue<RecoTask> *reco_bq = (static_cast<BlockQueues<CalcTask, RecoTask>*>(args))->r_bq;
    while(true)
    {
        RecoTask t;
        reco_bq->pop(&t);
        t();
        std::cout << "recorder thread 保存任务已完成" << std::endl;
    }
    return nullptr;
}

int main()
{
    srand((unsigned long)time(nullptr) ^ getpid());

    BlockQueues<CalcTask, RecoTask> bqs;
    bqs.c_bq = new BlockQueue<CalcTask>();
    bqs.r_bq = new BlockQueue<RecoTask>();

    // 多生产者 多消费者 单记录者
    // pthread_t p_er[2], c_er[3], re_er; // 生产者、消费者、记录员
    // pthread_create(&p_er[0], nullptr, producer, &bqs);
    // pthread_create(&p_er[1], nullptr, producer, &bqs);
    // pthread_create(&p_er[2], nullptr, producer, &bqs);
    // pthread_create(&c_er[0], nullptr, consumer, &bqs);
    // pthread_create(&c_er[1], nullptr, consumer, &bqs);
    // pthread_create(&re_er, nullptr, recorder, &bqs);

    // pthread_join(p_er[0], nullptr);
    // pthread_join(p_er[1], nullptr);
    // pthread_join(p_er[2], nullptr);
    // pthread_join(c_er[0], nullptr);
    // pthread_join(c_er[1], nullptr);
    // pthread_join(re_er, nullptr);

    // 单生产者 单消费者 单记录者
    pthread_t p_er, c_er, re_er; // 生产者、消费者、记录员
    pthread_create(&p_er, nullptr, producer, &bqs);
    pthread_create(&c_er, nullptr, consumer, &bqs);
    pthread_create(&re_er, nullptr, recorder, &bqs);

    pthread_join(c_er, nullptr);
    pthread_join(p_er, nullptr);
    pthread_join(re_er, nullptr);

    delete bqs.c_bq;
    delete bqs.r_bq;
    return 0;
}

实验现象观察与分析

若生产者生产慢一点(在producer函数里加个sleep(1);),消费者快一点

程序刚运行时,假设生产者先竞争到锁,就往阻塞队列里塞了挺多数据,此时消费者来了,由于消费的快,就会把阻塞队列里的数据消费完,程序稳定时,一定是空队列或者队列里只有一个数据的状态,生产一个新数据,消费一个新数据。

若生产者生产快一点,消费者慢一点(在consumer函数里加个sleep(1);)

程序刚运行时,假设生产者先竞争到锁,就往阻塞队列里塞了挺多数据,此时消费者来了,由于消费的慢,只消费了阻塞队列里的几个数据消,程序稳定时,一定是满队列或者队列里只有一个空位的状态,生产一个新数据,消费一个历史数据。

代码细节

1、加锁和解锁之间的条件判断

充当条件判断的语法必须是while,不能是if。因为从pthread_cond_wait醒来返回时,可以重新进入判断,避免伪唤醒情况!比如有1个消费者,10个生产者,此时只消费了一个数据,而用的是pthread cond broadcast广播给10个生产者线程,由于判断条件是if(isFul))执行流就直接往后走了,很有可能生产多了数据,所以要用while,让每个生产者线程醒过来的时候都再去判断一下有没有空位。

2、条件等待函数

在加锁后,若满足while判断条件,则调用pthread_cond_wait函数,它的第2个参数是mutex,目的是让该函数被调用的时候以原子性的方式释放锁,并将自己挂起;只要该函数被唤醒且成功返回时,就表示自动重新获取锁。若pthread_cond_wait函数调用失败?调用失败的话就不会返回了。

3、pthread cond signal最好放在临界资源区

pthread_cond_signal可以在临界区的内部,也可以在临界区外部。建议在临界资源去内部,因为对于线程本身来说,我的unlock和lock代码离得近,越方便我抢锁!

拓展1:生产者+消费者+记录员,就用两个阻塞队列来实现,单生产者和单消费者、单消费者和单记录者。

**拓展2:那多生产者和多消费者可以用这份代码实现吗?可以,push和pop里面都加锁了。**每个生产者都需要调用push接口,因为有锁的存在,可以实现生产者之间的互斥,消费者都调用pop接口同理。

pthread_t p_er[2], c_er[3], re_er; // 生产者、消费者、记录员
pthread_create(&p_er[0], nullptr, producer, &bqs);
pthread_create(&p_er[1], nullptr, producer, &bqs);
pthread_create(&p_er[2], nullptr, producer, &bqs);
pthread_create(&c_er[0], nullptr, consumer, &bqs);
pthread_create(&c_er[1], nullptr, consumer, &bqs);
pthread_create(&re_er, nullptr, recorder, &bqs);

pthread_join(p_er[0], nullptr);
pthread_join(p_er[1], nullptr);
pthread_join(p_er[2], nullptr);
pthread_join(c_er[0], nullptr);
pthread_join(c_er[1], nullptr);
pthread_join(re_er, nullptr);

思考

1、创建多线程生产和消费的意义是什么?

对于生产者而言,要向blockqueue里放数据;对于消费者而言,要从blockqueue拿出数据。

生产者放任务的预备工作是去构建任务(可能是从外设、网络、数据块这些地方拿来的用户数据),构建任务也是要花时间的;

消费者取出任务后还要执行该项任务(可能非常耗时)。

2、生产消费模型高效在哪里?

blockqueue这里的动作只允许串行执行,这里并不高效,而是体现在某一个线程放置/拿去任务时,并不影响其他线程构建任务/执行任务。在放置任务之前/拿出任务之后,允许多线程并行执行,才叫做高效!

3、就目前为止,代码还可以作何改进?

一个线程要想操作临界资源,该临界资源需满足某种条件(如生产者要push时,阻塞队列必须有一个空位;消费者要pop时,阻塞队列里至少有1个数据)。push和pop对应的代码里,流程是先加锁,再检测、再push/pop,再解锁(因为不访问临界资源,就不知道该临界资源是否满足条件>即检测的前提是先访问临界资源>访问之前要先加锁,临界资源就被整体锁定了==>当前线程拥有了对临界资源整体使用权。

第一点:在实际情况下,一份公共资源是允许同时访问不同区域的!即允许多线程并发访问公共资源不同区域。而我们目前的代码很明显是单线程独占公共资源,无法实现并发访问。

第二点:我们在操作临界资源的时候,有可能资源并不符合条件,但是线程无法提前得知,只能加锁访问,而频繁地加锁判断是否满足条件也意味着系统资源消耗,此处仍有不足。

故引入信号量来弥补不足。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/461711.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

Spring Security实战(九)—— 使用Spring Security OAuth实现OAuth对接

一、OAuth2.0介绍 OAuth2.0是一种授权协议&#xff0c;允许用户授权第三方应用程序代表他们获取受保护的资源&#xff0c;如个人信息或照片等。它允许用户授权访问他们存储在另一个服务提供商上的资源&#xff0c;而无需将其凭据共享给第三方应用程序。OAuth2.0协议建立在OAuth…

直升机空气动力学基础--004翼型的阻力

来源 1. 空气的粘性 2.阻力的产生 3.形成因素 4.阻力系数曲线

LeetCode-242. 有效的字母异位词

题目链接 LeetCode-242. 有效的字母异位词 题目描述 题解 题解一&#xff08;Java&#xff09; 作者&#xff1a;仲景 首先&#xff0c;满足条件的情况下&#xff0c;两个字符串的长度一定是相等的&#xff0c;不相等一定不满足条件 使用Hash表来存储字符串s中各个字符出现的…

回溯算法——我欲修仙(功法篇)

个人主页&#xff1a;【&#x1f60a;个人主页】 系列专栏&#xff1a;【❤️我欲修仙】 系列文章目录 第一章 ❤️ 学习前的必知知识 第二章 ❤️ 二分查找 文章目录 系列文章目录回溯算法&#x1f914;&#x1f914;&#x1f914;回溯算法一般可以解决的问题回溯算法的实现回…

Python语言简介

B站讲解视频&#xff1a;https://www.bilibili.com/video/BV1Mv4y1n7n8/?vd_source515e6808c21c69114a4fae34589dfb0e Python是什么 Python是一个高层次的结合了解释性、编译性、互动性和面向对象的脚本语言。具有很强的可读性&#xff0c;相比其他语言经常使用英文关键字&a…

为什么有时候磁珠会使电源的纹波变大

电路设计时&#xff0c;我们常常在芯片电源的输入放一个磁珠和电容&#xff0c;用以滤除电源上的高频噪声。 但是有时候会发现&#xff0c;加了磁珠后&#xff0c;芯片电源输入处纹波竟然变大了&#xff0c;超出了电源纹波范围&#xff0c;导致芯片工作异常。 把磁珠换成0R电阻…

论文阅读:Heterogeneous Graph Contrastive Learning for Recommendation(WSDM ’23)

论文链接 Motivation&#xff1a; 在推荐系统中&#xff0c;图神经网络在建模图结构数据上已经变成一个强有力的工具。但是现实生活的推荐语义通常涉及异质关系&#xff08;像用户的社交关系&#xff0c;物品知识关系的依赖&#xff09;&#xff0c;这些都包含丰富的语义信息…

Linux下实现C语言程序

一.情况说明 写这篇博客的情况比较复杂&#xff0c;首先我本来是参加新星计划按照规划现在去学习shell脚本语言的&#xff0c;但是博主现在由于其他原因需要了解makefile&#xff0c;makefile是Linux系统下的一种工具&#xff0c;makefile的一些背景要涉及链接库的知识&#xf…

从0开始搭建一个简单的前后端分离的XX系统-vue+Springboot+mybatis-plus+mysql

一、准备工作 1.安装node 2.idea 旗舰版** idea**教程 上述教程中的idea**工具 3.安装mysql任意版本 mysql 4.安装mysql workbench&#xff08;没用上&#xff09; 5.安装navicate 参考文章&#xff1a;百度-从小白到架构&#xff08;作者&#xff09;-Navicat16** Nav…

Thinkphp获取项目最近更改变动的所有文件

导读&#xff1a; 企业级的网站项目都是要不断优化迭代更新的&#xff0c;做为一名后端程序员&#xff0c;在编写更新模块时&#xff0c;如何能快速获取最近修改的文件&#xff0c;然后打包压缩成更新补丁呢&#xff1f;我们先来看一下最终效果图&#xff1a; 步骤&#xff1a…

使用FFMPEG分离mp4/flv文件中的264视频和aac音频

准备 ffmpeg 4.4 一个MP4或flv格式的视频文件 分离流程 大致分为以下几个简单步骤&#xff1a; 1.使用avformat_open_input 函数打开文件并初始化结构AVFormatContext 2.查找是否存在音频和视频信息 3.构建一个h264_mp4toannexb比特流的过滤器&#xff0c;用来给视频avpa…

Hudi 数据湖技术之集成Flink

目录 1 安装Flink2 快速入门2.1 集成Flink概述2.2 环境准备2.3 创建表2.4 插入数据2.5 查询数据2.6 更新数据 3 Streaming query3.1 创建表3.2 查询数据3.3 插入数据 4 Flink SQL Writer4.1 Flink SQL集成Kafka4.2 Flink SQL写入Hudi4.2.1 创建Maven Module4.2.2 消费Kafka数据…

【C++】了解设计模式、 stackqueue的使用与模拟实现

文章目录 1.设计模式2.stack1.stack的使用1.stack的结构2.stack的接口 2.stack的模拟实现1.stack的结构2.接口实现 3.queue1.queue的使用1.queue的结构3.queue的接口 2.queue的模拟实现1.queue的结构2.接口实现 4.了解deque1.deque的原理介绍2.deque的底层结构3.deque的迭代器设…

Codeforces Round 861 (Div. 2)(A~D)

A. Lucky Numbers 给出边界l和r&#xff0c;在区间[l, r]之间找到幸运值最大的数字。一个数字的幸运值被定义为数位差的最大值&#xff0c;即数字中最大的数位和最小的数位的差。 思路&#xff1a;因为涉及到至少两位&#xff0c;即个位和十位变化最快&#xff0c;最容易得到相…

Android四大组件之广播接收者BroadcastReceiver

一、全局广播 Android中的广播可以分为两种类型&#xff1a;标准广播和有序广播 标准广播&#xff1a;一种完全异步执行的广播&#xff0c;在广播发出之后&#xff0c;所有的广播接收器几乎都会同一时刻接收到这条广播消息&#xff0c;因此它们之间没有任何先后顺序。无法进行…

Vector-常用CAN工具 - 入门到精通 - 专栏链接

一、CANoe篇 1、CANoe入门到精通_软件安装 2、CANoe入门到精通_硬件及环境搭建 3、CANoe入门到精通_软件环境配置 4、CANoe入门到精通_Network Node CAPL开发 5、CANoe入门到精通_Node节点开发基本数据类型 6、CANoe入门到精通_Test Node节点开发设置 7、CANoe入门到精通…

《Cocos Creator游戏实战》AIGC之将草稿内容转为真实内容

目录 前言 训练AI 从识别结果中提取必要数据 发送图片并生成最终代码 总结与提高 资源下载 前言 当创作灵感来的时候&#xff0c;我们可能会先把灵感记录在草稿上&#xff0c;之后再去实现它。比方说有一天&#xff0c;我突然来了游戏创作灵感&#xff0c;想着那可以先把…

gpt 怎么用-免费gpt下载使用方法

gpt 怎么用 GPT&#xff08;Generative Pre-trained Transformer&#xff09;是一种基于Transformer的神经网络模型&#xff0c;用于自然语言处理任务&#xff0c;例如文本生成、摘要生成、翻译、问答等。以下是使用GPT进行文本生成的一般步骤&#xff1a; 首先&#xff0c;您…

编译预处理

编译预处理 1、宏定义1.1、 无参宏定义1.2、使用宏定义的优点1.3、宏定义注意点1.4、带参数的宏(重点)1.5、条件编译1.6、宏定义的一些巧妙用法(有用)1.7、结构体占用字节数的计算原则&#xff08;考题经常考&#xff0c;要会画图&#xff09;1.8、#在宏定义中的作用&#xff0…

转型产业互联网,新氧能否再造辉煌?

近年来&#xff0c;“颜值经济”推动医美行业快速发展&#xff0c;在利润驱动下&#xff0c;除了专注医美赛道的企业之外&#xff0c;也有不少第三方互联网平台正强势进入医美领域&#xff0c;使以新氧为代表的医美企业面对不小发展压力&#xff0c;同时也展现出强大的发展韧性…