美文网首页
第十三篇:GCD探究

第十三篇:GCD探究

作者: 坚持才会看到希望 | 来源:发表于2022-05-29 23:25 被阅读0次

    首先我们来看下面一段代码,我们发现运行后这个会造成死锁奔溃,这个原因是因为主程序标注1里这些代码是放在main_queue里的,这个是串行队列,先进入先执行,然后标注2里添加到main_queue里,标注2是后添加进去的,所以要标注1先完成,但是标注1完成的话就要等标注2完成,所以会造成死锁现象。

    - (void)viewDidLoad {//标注1
    
        [super viewDidLoad];
        dispatch_sync(dispatch_get_main_queue(), ^{//标注2
            
        }//标注2
    );
    }//标注1
    

    下面是奔溃信息跳到了DISPATCH_WAIT_FOR_QUEUE这里,我们全局搜索下看下。

    libdispatch.dylib`__DISPATCH_WAIT_FOR_QUEUE__:
        0x102e5980c <+0>:   stp    x22, x21, [sp, #-0x30]!
        0x102e59810 <+4>:   stp    x20, x19, [sp, #0x10]
        0x102e59814 <+8>:   stp    x29, x30, [sp, #0x20]
        0x102e59818 <+12>:  add    x29, sp, #0x20
        0x102e5981c <+16>:  mov    x20, x1
        0x102e59820 <+20>:  mov    x19, x0
        0x102e59824 <+24>:  ldr    x8, [x1, #0x38]
        0x102e59828 <+28>:  mov    x9, #0x2
        0x102e5982c <+32>:  movk   x9, #0x20, lsl #32
        0x102e59830 <+36>:  movk   x9, #0xff80, lsl #48
        0x102e59834 <+40>:  mov    x10, #0x2
        0x102e59838 <+44>:  movk   x10, #0x20, lsl #32
        0x102e5983c <+48>:  and    x11, x8, x9
        0x102e59840 <+52>:  cmp    x11, x10
        0x102e59844 <+56>:  b.ne   0x102e599e8               ; <+476>
        0x102e59848 <+60>:  orr    x21, x8, #0x800000000
    
    static void
    __DISPATCH_WAIT_FOR_QUEUE__(dispatch_sync_context_t dsc, dispatch_queue_t dq)
    {
        uint64_t dq_state = _dispatch_wait_prepare(dq);
        if (unlikely(_dq_state_drain_locked_by(dq_state, dsc->dsc_waiter))) {
            DISPATCH_CLIENT_CRASH((uintptr_t)dq_state,
                    "dispatch_sync called on queue"
                    "already owned by current thread");
        }
    

    通过下面代码分析要满足((lock_value ^ tid) & DLOCK_OWNER_MASK) == 0这个条件的时候会发生线程奔溃,也就是lock_value ^ tid(^这个是异或符号,相等为0,不等为1)为0时候,从而说明lock_value 和tid相等时候等式才成立才会奔溃。

    DISPATCH_ALWAYS_INLINE
    static inline bool
    _dispatch_lock_is_locked_by(dispatch_lock lock_value, dispatch_tid tid)
    {
    //lock_value 当前的队列
    //tid 当前的线程
        // equivalent to _dispatch_lock_owner(lock_value) == tid
        return ((lock_value ^ tid) & DLOCK_OWNER_MASK) == 0;
    }
    
    

    注意:死锁:在当前线程(和当前队列相关的)同步的向串行队列里面添加任务,就会死锁

    dispatch_get_main_queue就是主线程,也是串行的

    - (void)viewDidLoad {
        [super viewDidLoad];
        
        dispatch_queue_t t= dispatch_queue_create("hpw", DISPATCH_QUEUE_SERIAL);
        
        dispatch_sync(t, ^{
            dispatch_sync(dispatch_get_main_queue(), ^{
                
            });
        });  
    }
    
    

    再看一个对比例子:

    下面的这第一个就会崩溃,是因为它是在当前队列也就是t里面进行了线程添加操作,第二个不会是因为//标注5对应的dispatch_sync是对于当前主队列里,所以不会奔溃的。所以可以对我们上面所说的内容进行一个更详细的额外注释。

    注意:死锁:1)在当前线程(和当前队列相关的)同步的向串行队列里 面添加任务,就会死锁 2)和当前队列相关(线程执行的任务是从队列里面取出来的)

    - (void)viewDidLoad {
        [super viewDidLoad];
        
        dispatch_queue_t t= dispatch_queue_create("hpw", DISPATCH_QUEUE_SERIAL);
        
        dispatch_sync(t, ^{
            dispatch_sync(t, ^{
                
            });
        });
    
    }
    
    
    - (void)viewDidLoad {
        [super viewDidLoad];
        
        dispatch_queue_t t= dispatch_queue_create("hpw", DISPATCH_QUEUE_SERIAL);
        
    //标注5
        dispatch_sync(t, ^{
            NSLog(@"1");
        }) 
    }
    
    

    同步函数的特点

    1. 阻塞当前线程进⾏等待,直到当前添加到队列的任务执⾏完成。
    2. 只能在当前线程执⾏任务,不具备开启新线程的能⼒。

    异步函数的特点

    1. 不会阻塞线程,不需要等待,任务可以继续执⾏。
    2. 可以在新的线程执⾏任务,具备开启新线程的能⼒。(并发队列可以开启多条⼦线程,串⾏队
      列只能开启⼀条⼦线程)

    其实block就是一个Block_layout的结构体,执行block也就是执行其里面的invoke

    #ifdef __BLOCKS__
    #define _dispatch_Block_invoke(bb) \
            ((dispatch_function_t)((struct Block_layout *)bb)->invoke)
    

    下面是执行block的,但是下面在执行block前会有很多条件判断去执行func,这个是因为我们有不同种队列,有串行队列,并行队列,主队列等等。

    DISPATCH_ALWAYS_INLINE
    static inline void
    _dispatch_sync_f_inline(dispatch_queue_t dq, void *ctxt,
            dispatch_function_t func, uintptr_t dc_flags)
    {
        if (likely(dq->dq_width == 1)) {
            return _dispatch_barrier_sync_f(dq, ctxt, func, dc_flags);
        }
    
        if (unlikely(dx_metatype(dq) != _DISPATCH_LANE_TYPE)) {
            DISPATCH_CLIENT_CRASH(0, "Queue type doesn't support dispatch_sync");
        }
    
        dispatch_lane_t dl = upcast(dq)._dl;
        // Global concurrent queues and queues bound to non-dispatch threads
        // always fall into the slow case, see DISPATCH_ROOT_QUEUE_STATE_INIT_VALUE
        if (unlikely(!_dispatch_queue_try_reserve_sync_width(dl))) {
            return _dispatch_sync_f_slow(dl, ctxt, func, 0, dl, dc_flags);
        }
    
        if (unlikely(dq->do_targetq->do_targetq)) {
            return _dispatch_sync_recurse(dl, ctxt, func, dc_flags);
        }
        _dispatch_introspection_sync_begin(dl);
        _dispatch_sync_invoke_and_complete(dl, ctxt, func DISPATCH_TRACE_ARG(
                _dispatch_trace_item_sync_push_pop(dq, ctxt, func, dc_flags)));
    }
    

    下面这个是对block的执行,其中f(ctxt)是执行的任务

    void
    _dispatch_client_callout(void *ctxt, dispatch_function_t f)
    {
        _dispatch_get_tsd_base();
        void *u = _dispatch_get_unwind_tsd();
        if (likely(!u)) return f(ctxt);
        _dispatch_set_unwind_tsd(NULL);
        f(ctxt);
        _dispatch_free_unwind_tsd();
        _dispatch_set_unwind_tsd(u);
    }
    

    我们在看同步函数的代码时候,发现dispatch_sync没有看到线程的东西,同时也没有保存任务,都是立即直接执行了任务。

    接着我们看下异步函数

    首先看下其对应的源码,在关注异步原理时,我们也要从这个work进行下手。

    #ifdef __BLOCKS__
    void
    dispatch_async(dispatch_queue_t dq, dispatch_block_t work)
    {
        dispatch_continuation_t dc = _dispatch_continuation_alloc();
        uintptr_t dc_flags = DC_FLAG_CONSUME;
        dispatch_qos_t qos;
    
        qos = _dispatch_continuation_init(dc, dq, work, 0, dc_flags);
        _dispatch_continuation_async(dq, dc, qos, dc->dc_flags);
    }
    #endif
    

    其先对work进行了一个copy的操作,_dispatch_Block_copy(work)

    DISPATCH_ALWAYS_INLINE
    static inline dispatch_qos_t
    _dispatch_continuation_init(dispatch_continuation_t dc,
            dispatch_queue_class_t dqu, dispatch_block_t work,
            dispatch_block_flags_t flags, uintptr_t dc_flags)
    {
        void *ctxt = _dispatch_Block_copy(work);
    
        dc_flags |= DC_FLAG_BLOCK | DC_FLAG_ALLOCATED;
        if (unlikely(_dispatch_block_has_private_data(work))) {
            dc->dc_flags = dc_flags;
            dc->dc_ctxt = ctxt;
            // will initialize all fields but requires dc_flags & dc_ctxt to be set
            return _dispatch_continuation_init_slow(dc, dqu, flags);
        }
    
        dispatch_function_t func = _dispatch_Block_invoke(work);
        if (dc_flags & DC_FLAG_CONSUME) {
            func = _dispatch_call_block_and_release;
        }
        return _dispatch_continuation_init_f(dc, dqu, ctxt, func, flags, dc_flags);
    }
    

    也是封装成invoke这个函数去处理

    #ifdef __BLOCKS__
    #define _dispatch_Block_invoke(bb) \
            ((dispatch_function_t)((struct Block_layout *)bb)->invoke)
    

    通过上面源码分析,我们知道其会保存block,dispatch_async -- 保存block,RAC也就是其函数式编程,这里保存block也就是当使用的时候进行一个调用出来。上面看的都是源码,下面我们通过bt指令看堆栈信息。

    - (void)viewDidLoad {
        [super viewDidLoad];
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
            
        });  
    }
    
    (lldb) bt
    * thread #3, queue = 'com.apple.root.default-qos', stop reason = breakpoint 1.1
      * frame #0: 0x0000000104251e6c 001--GCD`__29-[ViewController viewDidLoad]_block_invoke(.block_descriptor=0x0000000104254090) at ViewController.m:21:5
        frame #1: 0x00000001045c033c libdispatch.dylib`_dispatch_call_block_and_release + 24
        frame #2: 0x00000001045c1b94 libdispatch.dylib`_dispatch_client_callout + 16
        frame #3: 0x00000001045c43e4 libdispatch.dylib`_dispatch_queue_override_invoke + 952
        frame #4: 0x00000001045d3fec libdispatch.dylib`_dispatch_root_queue_drain + 440
        frame #5: 0x00000001045d4ab8 libdispatch.dylib`_dispatch_worker_thread2 + 188
        frame #6: 0x00000001cba87a98 libsystem_pthread.dylib`_pthread_wqthread + 224
    (lldb)  
    

    通过上面bt堆栈信息,可以看出dispatch_async会有对线程的一个操作。

    题型1:

    7,8,9顺序不确定,但是必定在0后面,0肯定在3后面 ,1,2和3,0是没有任何顺序关系的。

    -(void)test1 {
        
        dispatch_queue_t queue = dispatch_queue_create("hpw", DISPATCH_QUEUE_CONCURRENT);
        dispatch_async(queue, ^{
            NSLog(@"1");
        });
        dispatch_async(queue, ^{
            NSLog(@"2");
        });
        dispatch_sync(queue, ^{//同步函数,立即执行,所以3在0之前
            NSLog(@"3");
        });
        NSLog(@"0");
        dispatch_async(queue, ^{
            NSLog(@"7");
        });
        dispatch_async(queue, ^{
            NSLog(@"8");
        });
        dispatch_async(queue, ^{
            NSLog(@"9");
        });
    }
    
    题型2:

    2,4,5这个顺序是肯定的,

    -(void)test2 {
        
        dispatch_queue_t t = dispatch_queue_create("hpw", DISPATCH_QUEUE_CONCURRENT);
        NSLog(@"1");
        dispatch_sync(t, ^{//同步立即执行
            NSLog(@"2");
            dispatch_async(t, ^{
                NSLog(@"3");
            });
            NSLog(@"4");
        });
        NSLog(@"5");
    }
    
    题型3:

    下面不会死锁,3一定是在4后面,这个是因为2,4是先添加到队列t里的,3是后添加进去的,所以等4执行后再执行3.

    -(void)test5 {
        
        dispatch_queue_t t = dispatch_queue_create("hpw", DISPATCH_QUEUE_SERIAL);
        NSLog(@"1");
        dispatch_sync(t, ^{
            NSLog(@"2");
            dispatch_async(t, ^{
                NSLog(@"3");
            });
            NSLog(@"4");
        });
        NSLog(@"5");
    }
    
    题型4:

    并发队列不可能死锁,肯定的顺序是2,3,4, 5和234没有顺序

    -(void)test6 {
        
        dispatch_queue_t t = dispatch_queue_create("hpw", DISPATCH_QUEUE_CONCURRENT);
        NSLog(@"1");
        dispatch_async(t, ^{
            NSLog(@"2");
            dispatch_sync(t, ^{//同步函数立即执行
                NSLog(@"3");
            });
            NSLog(@"4");
        });
        NSLog(@"5");
    }
    
    题型5:

    大于等于100

        self.num = 0;
        while (self.num < 100) {//while会卡住主线程
            dispatch_async(dispatch_get_global_queue(0, 0), ^{
                self.num ++;
            });
        }
        NSLog(@"self.num = %d",self.num);
    }
    
    题型6:

    结果是<=100

    - (void)test4 {
        self.num = 0;
        for (int i = 0; i < 100; i ++) {
            dispatch_async(dispatch_get_global_queue(0, 0), ^{
                self.num ++;
            });
        }
        NSLog(@"self.num = %d",self.num);
    }
    

    GCD里单例

     static dispatch_once_t onceToken;
        dispatch_once(&onceToken,^{
            
        });
    

    下面是GCD里的单例源码实现:

    void
    dispatch_once(dispatch_once_t *val, dispatch_block_t block)
    {
        dispatch_once_f(val, block, _dispatch_Block_invoke(block));
    }
    
    void
    dispatch_once_f(dispatch_once_t *val, void *ctxt, dispatch_function_t func)
    {
        dispatch_once_gate_t l = (dispatch_once_gate_t)val;
    
    #if !DISPATCH_ONCE_INLINE_FASTPATH || DISPATCH_ONCE_USE_QUIESCENT_COUNTER
        uintptr_t v = os_atomic_load(&l->dgo_once, acquire);
        if (likely(v == DLOCK_ONCE_DONE)) {
            return;
        }
    #if DISPATCH_ONCE_USE_QUIESCENT_COUNTER
        if (likely(DISPATCH_ONCE_IS_GEN(v))) {
            return _dispatch_once_mark_done_if_quiesced(l, v);
        }
    #endif
    #endif
        if (_dispatch_once_gate_tryenter(l)) {
            return _dispatch_once_callout(l, ctxt, func);
        }
        return _dispatch_once_wait(l);
    }
    

    下面这个原子操作,比较+交换,就是判断dgo_once和DLOCK_ONCE_UNLOCKED是否相等

    static inline bool
    _dispatch_once_gate_tryenter(dispatch_once_gate_t l)
    {
        return os_atomic_cmpxchg(&l->dgo_once, DLOCK_ONCE_UNLOCKED,
                (uintptr_t)_dispatch_lock_value_for_self(), relaxed);
    }
    

    下面这个是去执行我们block源码_dispatch_once_callout

    DISPATCH_NOINLINE
    static void
    _dispatch_once_callout(dispatch_once_gate_t l, void *ctxt,
            dispatch_function_t func)
    {
        _dispatch_client_callout(ctxt, func);
        _dispatch_once_gate_broadcast(l);
    }
    
    
    GCD单例里最核心的代码原理

    基本思想就是通过状态的判断使得block只被调⽤⼀次。同时这里也有三种情况的处理,当1.block没有执行的时候,2.block正在被执行的时候,3.block被执行过的时候
    核⼼代码:


    栅栏函数

    这里栅栏函数里3要等1,2都输出执行后才会执行,5的输出必须要等到3输出后才会输出,同时必须要是自己创建的队列。如果这个队列是 dispatch_get_global_queue(全局并发队列)或者其他的系统的队列不是自己创建的队列那不满足栅栏打印的顺序。

    栅栏函数有同步和异步的,当下面为dispatch_barrier_sync同步函数时候,就会使得4在3后面打印,1,2在3前面。

      dispatch_queue_t t = dispatch_queue_create("hpw", DISPATCH_QUEUE_CONCURRENT);
        dispatch_async(t, ^{
            NSLog(@"1");
        });
        dispatch_async(t, ^{
            NSLog(@"2");
        });
        // 栅栏函数
        dispatch_barrier_async(t, ^{//异步函数
            NSLog(@"3");
        });
    
        NSLog(@"4");
    
        dispatch_async(t, ^{
            NSLog(@"5");
        });
    

    全局并发队列无法控制栅栏函数,我们创建的并发队列就可以,这个是因为下面我们创建的并发队列会有一个判断DISPATCH_WAKEUP_BARRIER_COMPLETE,这个就是判断栅栏函数是否完成。

    为什么栅栏函数对全局并发队列没有效果,这个是因为全局并发队列我们系统也在用,如果使用栅栏函数能够阻塞这个队列,那我们系统的一些操作也会被阻塞。

    DISPATCH_NOINLINE
    void
    _dispatch_lane_wakeup(dispatch_lane_class_t dqu, dispatch_qos_t qos,
            dispatch_wakeup_flags_t flags)
    {
        dispatch_queue_wakeup_target_t target = DISPATCH_QUEUE_WAKEUP_NONE;
    
        if (unlikely(flags & DISPATCH_WAKEUP_BARRIER_COMPLETE)) {
            return _dispatch_lane_barrier_complete(dqu, qos, flags);
        }
        if (_dispatch_queue_class_probe(dqu)) {
            target = DISPATCH_QUEUE_WAKEUP_TARGET;
        }
        return _dispatch_queue_wakeup(dqu, qos, flags, target);
    }
    

    栅栏函数
    栅栏函数的效果:等待栅栏函数前添加到队列⾥⾯的任务全部执⾏完成之后,才会执⾏栅栏函数⾥
    ⾯的任务,栅栏函数⾥⾯的任务执⾏完成之后才会执⾏栅栏函数后⾯的队列⾥⾯的任务。
    需要注意的点:

    1. 栅栏函数只对同⼀队列起作⽤。
    2. 栅栏函数对全局并发队列⽆效。

    GCD调度组

    调度组的功能比栅栏函数强大些。栅栏函数是对应队列,调度组对应的是group,

    下面是个调度组,dispatch_group_notify里的是在所有前面都完成后再会执行的。这里的 dispatch_group_enter和dispatch_group_leave 函数也就是dispatch_group_async(<#dispatch_group_t _Nonnull group#>, <#dispatch_queue_t _Nonnull queue#>, <#^(void)block#>)函数。

    下面的dispatch_group_enter,dispatch_group_leave这些其实也就是对一个组里的任务的计数加减,当这个引用计数为0的时候也就执行dispatch_group_notify这个函数里代码。

    - (void)viewDidLoad {
        [super viewDidLoad];
        
        dispatch_group_t g = dispatch_group_create();
        dispatch_queue_t que1 = dispatch_queue_create("lg1", DISPATCH_QUEUE_CONCURRENT);
        dispatch_queue_t que2 = dispatch_queue_create("lg2", DISPATCH_QUEUE_CONCURRENT);
        
        dispatch_group_enter(g);
        dispatch_async(que1, ^{
            sleep(2);
            NSLog(@"1");
            dispatch_group_leave(g);
        });
    
        dispatch_group_enter(g);
        dispatch_async(que2, ^{
            sleep(3);
            NSLog(@"2");
            dispatch_group_leave(g);
        });
        
        dispatch_group_enter(g);
        dispatch_async(dispatch_get_global_queue(0, 0 ), ^{
            sleep(4);
            NSLog(@"3");
            dispatch_group_leave(g);
        });
        
        dispatch_group_enter(g);
        dispatch_async(dispatch_get_main_queue(), ^{
            sleep(5);
            NSLog(@"4");
            dispatch_group_leave(g);
        });
    
        dispatch_group_notify(g, dispatch_get_global_queue(0, 0), ^{
            NSLog(@"5");
        });
    }
    
    

    下面这个源码就是判断_dispatch_group_notify里的old_state这个是否为0,也就是一个组里的任务的计数,为0的时候其就会调用_dispatch_group_wake方法来进行唤醒。

    DISPATCH_ALWAYS_INLINE
    static inline void
    _dispatch_group_notify(dispatch_group_t dg, dispatch_queue_t dq,
            dispatch_continuation_t dsn)
    {
        uint64_t old_state, new_state;
        dispatch_continuation_t prev;
    
        dsn->dc_data = dq;
        _dispatch_retain(dq);
    
        prev = os_mpsc_push_update_tail(os_mpsc(dg, dg_notify), dsn, do_next);
        if (os_mpsc_push_was_empty(prev)) _dispatch_retain(dg);
        os_mpsc_push_update_prev(os_mpsc(dg, dg_notify), prev, dsn, do_next);
        if (os_mpsc_push_was_empty(prev)) {
            os_atomic_rmw_loop2o(dg, dg_state, old_state, new_state, release, {
                new_state = old_state | DISPATCH_GROUP_HAS_NOTIFS;
                if ((uint32_t)old_state == 0) {
                    os_atomic_rmw_loop_give_up({
                        return _dispatch_group_wake(dg, new_state, false);
                    });
                }
            });
        }
    }
    

    信号量

    dispatch_semaphore_create创建一个信号量,可以设置其信号量大小
    dispatch_semaphore_signal发送信号量,发送的时候会将信号量的值加1
    dispatch_semaphore_wait等待信号量,当信号量的值为0的时候其就会一直等待,阻塞线程一直等待直到信号量的值大于等于1的时候就会对信号量进行减1的操作

    如果开始dispatch_semaphore_create(0)我们将其设置成dispatch_semaphore_create(1)那么就会有两个队列进行工作,前面两个不用等待。如果改成2那么前面三个不用等待。

    信号量的好处:能够控制并发数量

    - (void)viewDidLoad {
        [super viewDidLoad];
       
        dispatch_semaphore_t sem = dispatch_semaphore_create(0);
        
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
            NSLog(@"1");
            dispatch_semaphore_signal(sem);
        });
        
        dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER);
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
            NSLog(@"2");
            dispatch_semaphore_signal(sem);
        });
        
        dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER);
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
            NSLog(@"3");
            dispatch_semaphore_signal(sem);
        });
        
        dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER);
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
            
            NSLog(@"4");
            dispatch_semaphore_signal(sem);
        });
        
        dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER);
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
            NSLog(@"5");
            dispatch_semaphore_signal(sem);
        });
    }
    

    下面是信号量等待的源码,当其value值大于0时候就无需等待,反之就需要等待。

    intptr_t
    dispatch_semaphore_wait(dispatch_semaphore_t dsema, dispatch_time_t timeout)
    {
        long value = os_atomic_dec2o(dsema, dsema_value, acquire);
        if (likely(value >= 0)) {
            return 0;
        }
        return _dispatch_semaphore_wait_slow(dsema, timeout);
    }
    

    下面是信号量一直进行等待的操作

    void
    _dispatch_sema4_wait(_dispatch_sema4_t *sema)
    {
        int ret = 0;
        do {
            ret = sem_wait(sema);
        } while (ret == -1 && errno == EINTR);
        DISPATCH_SEMAPHORE_VERIFY_RET(ret);
    }
    

    dispatch sources用法

    dispatch_source是⽤来监听事件的,可以创建不同类型的dispatch_source来监听不同的事件。
    dispatch_source可以监听的事件类型:

    dispatch_source的具体⽤法:在任⼀线程上调⽤它的dispatch_source_merge_data函数,会执⾏
    dispatch_source事先定义好的句柄(可以把句柄简单理解为⼀个block)。
    dispatch_source的⼏个⽅法:

    WechatIMG2015.jpeg

    相关文章

      网友评论

          本文标题:第十三篇:GCD探究

          本文链接:https://www.haomeiwen.com/subject/vwdeprtx.html