Java 并发编程 --ReentrantLock

J.U.C 简介

Java.util.concurrent 是在并发编程中比较常用的工具类,里面包含很多用来在并发场景中使用的组件,比如线程池、阻塞队列、计时器、同步器、并发集合等等。接下来我们会了解一下经典的比较常用组件的设计思想

Lock

Lock 在 J.U.C 中是最核心的组件,如果我们去看 J.U.C 包中的所有组件,我们可以发现绝大多数组件都有用到了 Lock。在 Lock 接口出现之前,Java 中的应用程序对于多线程的并发安全处理只能基于 synchronized 关键字来解决。但是 synchronized 关键字在有些场景中会存在一些短板,也就是它并不适用于所有的并发场景,但是 Java 5 之后 Lock 的出现可以解决 synchronized 在某些场景中的短板,它比 synchronized 更加灵活。

Lock 本质上是一个接口,它定义了释放锁和获得锁的方法,定义成接口就意味着它定义了锁的一个标准规范,也意味着锁的不同实现。实现 Lock 接口的类有很多,以下为几个常见的实现:

ReentrantLock: 表示重入锁,它是唯一一个实现了 Lock 接口的类。重入锁指的是线程获得锁后,再次获得该锁不需要阻塞,而是直接关联一次计数器增加重入次数。

ReentrantReadWriteLock: 冲入读写锁,它实现了 ReadWriteLock 接口,在这个类中维护了两个锁,一个是 ReadLock,一个是 WriteLock;他们都分别实现了 Lock 接口。读写锁是一种适合读多写少的场景下解决线程安全问题的工具,基本原则是:读和读不互斥、读和写互斥、写和写互斥。也就是说涉及到影响数据变化的操作都存在互斥。

StampedLock: stampedLock 是 JDK 8 引入的新的锁机制,可以简单认为是读写锁的一个改进版,读写锁虽然通过分离读和写的功能使得读和读之间可以完全并发,但是读和写是有冲突的,如果大量的读线程存在,可能会引起写线程的饥饿。stampedLock 是一种乐观的读策略,使得乐观锁完全不会阻塞写线程。

Lock 的类关系图

ReentrantLock 重入锁

重入锁,表示支持重新进入的锁,也就是说如果当前线程 t1 通过调用 lock 方法获取了锁之后,再次调用 lock,是不会再阻塞去获取锁的,直接增加重试次数就可以了。synchronized 和 ReentrantLock 都是可重入锁,比如下面这类场景中存在多个加锁方法的相互调用,其实就是一种重入特性的场景。

重入锁设计的目的

比如下面代码通过调用 demo 方法获得了当前的对象锁,然后在这个方法中再去调用 demo2,demo2 中也存在同一个实例锁,这个时候当前线程会因为无法获得 demo2 的对象锁而阻塞,就会产生死锁。重入锁的设计目的就是为了避免死锁。

public class ReentrantLockDemo {
	public synchronized void demo() {
		System.out.println("begin demo");
		demo2();
	}
	public void demo2() {
		System.out.println("begin demo1");
		synchronized (this) {
		}
	}
	public static void main(String[] args) {
		ReentrantLockDemo rl = new ReentrantLockDemo();
		new Thread(rl::demo).start();
	}
}

 ReentractLock 的使用案例:

public class AtomicDemo {
	private static int count = 0;
	static Lock lock = new ReentrantLock();
	public static void incr() {
		lock.lock();
		try {
			Thread.sleep(1);
			count++;
		} catch (Exception e) {
			e.printStackTrace();
		} finally {
			lock.unlock();
		}
	}
	public static void main(String[] args) throws Exception {
		for (int i = 0; i < 1000; i++) {
			new Thread(() -> {AtomicDemo.incr();}).start();
		}
		Thread.sleep(3000);
		System.out.println(count);
	}
}

ReentrantReadWriteLock

我们以前理解的锁基本都是排他锁,也就是这些锁在同一时刻只允许一个线程进行访问,而读写锁在同一时刻允许多个线程访问,但是在写线程访问时,所有的读线程和其他线程读会被阻塞。读写锁维护了一对锁,一个读锁,一个写锁;一般情况下,读写锁的性能都比排它锁好,因为大多数场景是读多余写的。在读多余写的情况下,读写锁能提供比排它锁更好的并发性和吞吐量。

public class LockDemo {
	static Map<String, Object> map = new HashMap<String, Object>();
	static ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
	static Lock read = rwl.readLock();
	static Lock write = rwl.writeLock();
	
	public static final Object get(String key) {
		System.out.println("开始读取数据");
		read.lock();
		try {
			return map.get(key);
		} finally {
			read.unlock();
		}
	}
	public static final void put(String key, Object obj) {
		System.out.println("开始写入数据");
		write.lock();
		try {
			map.put(key, obj);
		} finally {
			write.unlock();
		}
	}
}

在这个案例中,通过 HashMap 来模拟了一个内存缓存,然后使用读写锁来保证这个内存缓存的线程安全性。当执行读操作的时候,需要获取读锁,在并发访问的时候读锁不会被阻塞,因为读操作不会影响执行结果。

在执行写操作的时候,线程必须要获取写锁,当已经有线程持有写锁的情况下,当前线程会被阻塞,只有当写锁释放后,其他读写操作才能执行。使用读写锁可提升读操作的并发性,也保证每次写操作对所有的读写操作的可见性。

  1. 读锁与读锁可以共享

  2. 读锁与写锁不可以共享

  3. 写锁与写锁不可以共享

ReentrantLock 的实现原理

我们知道锁的基本原理是基于将多线程并行任务通过某一种机制实现线程的串行执行,从而达到线程安全的目的。在之前的 synchronized 中,我们了解了偏向锁、轻量级锁、乐观锁,基于乐观锁以及自旋锁来优化了 synchronized 的加锁开销,同时在重量级锁阶段,通过线程的阻塞和唤醒来达到线程竞争和同步的目的。

那么在 ReentrantLock 中,也一定会存在这样的需要去解决的问题,就是在多线程竞争重入锁时,竞争失败的线程时如何实现阻塞以及被唤醒的呢?

在 Lock 中,用到了一个同步队列 AQS,全程 AbstractQueuedSynchronizer,它是一个同步工具也是 Lock 用来实现线程同步的核心组件。如果搞懂了 AQS,那么 J.U.C 中绝大部分工具都能轻松掌握。

AQS 的两种功能

从使用层面来说,AQS 的功能分为两种:独占和共享

独占锁:每次只能有一个线程持有锁,比如前面演示的 ReentrantLock 就是以独占方式实现的互斥锁

共享锁:允许多个线程同时获得锁,并发访问共享资源,比如 ReentrantReadWriteLock

AQS 的内部实现

AQS 队列内部维护的是一个 FIFO 的双向链表,这种结构的特点是每个数据结构都有两个指针,分别指向直接的后继节点和直接的前驱节点,所以双向链表可以从任意一个节点开始可以很方便的访问前驱和后继节点。每个 Node 其实都是由线程封装的,当前线程争抢锁失败后会封装成 Node 加入到 AQS 队列中去;当获得锁的线程释放锁以后,会从队列中唤醒一个阻塞的节点

Node 的组成:

static final class Node {
        static final Node SHARED = new Node();
        static final Node EXCLUSIVE = null;

        /** waitStatus value to indicate thread has cancelled */
        static final int CANCELLED =  1;
        /** waitStatus value to indicate successor's thread needs unparking */
        static final int SIGNAL    = -1;
        /** waitStatus value to indicate thread is waiting on condition */
        static final int CONDITION = -2;
        /**
         * waitStatus value to indicate the next acquireShared should
         * unconditionally propagate
         */
        static final int PROPAGATE = -3;

        /**
         * Status field, taking on only the values:
         *   SIGNAL:     The successor of this node is (or will soon be)
         *               blocked (via park), so the current node must
         *               unpark its successor when it releases or
         *               cancels. To avoid races, acquire methods must
         *               first indicate they need a signal,
         *               then retry the atomic acquire, and then,
         *               on failure, block.
         *   CANCELLED:  This node is cancelled due to timeout or interrupt.
         *               Nodes never leave this state. In particular,
         *               a thread with cancelled node never again blocks.
         *   CONDITION:  This node is currently on a condition queue.
         *               It will not be used as a sync queue node
         *               until transferred, at which time the status
         *               will be set to 0. (Use of this value here has
         *               nothing to do with the other uses of the
         *               field, but simplifies mechanics.)
         *   PROPAGATE:  A releaseShared should be propagated to other
         *               nodes. This is set (for head node only) in
         *               doReleaseShared to ensure propagation
         *               continues, even if other operations have
         *               since intervened.
         *   0:          None of the above
         *
         * The values are arranged numerically to simplify use.
         * Non-negative values mean that a node doesn't need to
         * signal. So, most code doesn't need to check for particular
         * values, just for sign.
         *
         * The field is initialized to 0 for normal sync nodes, and
         * CONDITION for condition nodes.  It is modified using CAS
         * (or when possible, unconditional volatile writes).
         */
        volatile int waitStatus;

        /**
         * Link to predecessor node that current node/thread relies on
         * for checking waitStatus. Assigned during enqueuing, and nulled
         * out (for sake of GC) only upon dequeuing.  Also, upon
         * cancellation of a predecessor, we short-circuit while
         * finding a non-cancelled one, which will always exist
         * because the head node is never cancelled: A node becomes
         * head only as a result of successful acquire. A
         * cancelled thread never succeeds in acquiring, and a thread only
         * cancels itself, not any other node.
         */
        volatile Node prev;  // 前驱节点
        /**
         * Link to the successor node that the current node/thread
         * unparks upon release. Assigned during enqueuing, adjusted
         * when bypassing cancelled predecessors, and nulled out (for
         * sake of GC) when dequeued.  The enq operation does not
         * assign next field of a predecessor until after attachment,
         * so seeing a null next field does not necessarily mean that
         * node is at end of queue. However, if a next field appears
         * to be null, we can scan prev's from the tail to
         * double-check.  The next field of cancelled nodes is set to
         * point to the node itself instead of null, to make life
         * easier for isOnSyncQueue.
         */
        volatile Node next; // 后继节点
        /**
         * The thread that enqueued this node.  Initialized on
         * construction and nulled out after use.
         */
        volatile Thread thread; // 当前线程
        /**
         * Link to next node waiting on condition, or the special
         * value SHARED.  Because condition queues are accessed only
         * when holding in exclusive mode, we just need a simple
         * linked queue to hold nodes while they are waiting on
         * conditions. They are then transferred to the queue to
         * re-acquire. And because conditions can only be exclusive,
         * we save a field by using special value to indicate shared
         * mode.
         */
        Node nextWaiter;  // 存储在Condition队列中的后继节点
        /**
         * Returns true if node is waiting in shared mode.
         */
        final boolean isShared() {
            return nextWaiter == SHARED;
        }
        /**
         * Returns previous node, or throws NullPointerException if null.
         * Use when predecessor cannot be null.  The null check could
         * be elided, but is present to help the VM.
         *
         * @return the predecessor of this node
         */
        final Node predecessor() throws NullPointerException {
            Node p = prev;
            if (p == null)
                throw new NullPointerException();
            else
                return p;
        }
        Node() {    // Used to establish initial head or SHARED marker
        }
        Node(Thread thread, Node mode) {     // Used by addWaiter
            this.nextWaiter = mode;
            this.thread = thread;
        }
        Node(Thread thread, int waitStatus) { // Used by Condition
            this.waitStatus = waitStatus;
            this.thread = thread;
        }
    }

 

释放锁以及添加线程对于队列的变化

当出现锁竞争以及释放锁的时候,AQS 同步队列中的节点会发生变化,首先看一下添加节点的场景

这个场景会涉及两个变化:

  1. 新的线程封装成 Node 添加到同步队列中,设置 prev 节点以及修改当前节点的前置节点的 next 指向自己

  2. 通过 CAS 将 tail 重新指向新的尾部节点

head 节点表示获取锁成功的节点,当头节点释放同步状态时会唤醒后继节点,如果后继节点获得锁成功,会把自己设置成头节点,节点的变化过程如下:

这个过程也是涉及两个变化:

  1. 修改 head 节点指向下一个获得锁的节点

  2. 新获得锁的节点,将 prev 的指针指向 null

设置 head 节点不需要 CAS,原因是设置 head 节点是由获得锁的线程来完成的,而同步锁只能有一个线程获得,所以不需要 CAS 保证,只需要把 head 节点设置为原首节点的后继节点,并断开原首节点的 next 引用即可。

ReentrantLock 的源码分析

接下来以 ReentrantLock 为切入点,看看在这个场景中是如何是用 AQS 来实现线程同步的:

调用 ReentrantLock 中的 lock 方法,源码的调用过程如下面的时序图所示:

这个方式是 ReentrantLock 获取锁的入口:

public void lock() {
	sync.lock();
}

 sync 实际上是一个抽象的静态内部类,它继承了 AQS 来实现重入锁的逻辑,我们前面说过 AQS 是一个同步队列,它能够实现线程的阻塞以及唤醒,但它并不具备业务功能,所以在不同的同步场景中,会继承 AQS 来实现对应场景的功能:

Sync 有两个具体的实现类,分别是:

NofairSync: 表示可以存在抢占锁的功能,也就是说不管当前队列上是否存在其他线程等待,新线程都有机会抢占锁

FairSync: 表示所有线程都严格按照 FIFO 来获取锁

NofairSync.lock

以非公平锁为例子,来看看 lock 中的实现:

  1. 非公平锁和公平锁最大的区别在于,在非公平锁中我抢占锁的逻辑是不管有没有线程排队,我先上来 CAS 去抢占一下

  2. CAS 成功,就表示成功获得了锁

  3. CAS 失败,调用 acquire(1)走锁竞争逻辑

static final class NonfairSync extends Sync {
        private static final long serialVersionUID = 7316153563782823691L;

        /**
         * Performs lock.  Try immediate barge, backing up to normal
         * acquire on failure.
         */
        final void lock() {
            if (compareAndSetState(0, 1))
                setExclusiveOwnerThread(Thread.currentThread());
            else
                acquire(1);
        }

        protected final boolean tryAcquire(int acquires) {
            return nonfairTryAcquire(acquires);
        }
    }

 CAS 实现原理

protected final boolean compareAndSetState(int expect, int update) {

	return unsafe.compareAndSwapInt(this, stateOffset, expect, update);

}

 

通过 CAS 乐观锁的方式来做比较并替换,这段代码的意思是如果当前内存中的 state 的值和预期值 expect 相等,则替换为 update。更新成功返回 true,否则返回 false。

这个操作是原子的,不会出现线程安全问题,这里面涉及到 Unsafe 这个类的操作,以及涉及到 state 属性的意义。

State 是 AQS 中的一个属性,它在不同的实现中表达的含义不一样,对于同步锁的实现来说,表示一个同步状态,有两个含义的表示:

  1. 当 state=0 时,表示无锁状态

  2. 当 state>0 时,表示已经有线程获得了锁,也就是 state=1;但因为 ReentrantLock 允许重入,所以同一个线程多次获的同步锁的时候,state 会递增,比如重入 5 次,那么 state=5。而在释放锁的时候,同样需要释放 5 次,直到 state=0 其他线程才有资格获得锁。

AQS.acquire

acquire 是 AQS 中的方法,如果 AQS 操作未能成功,说明 state 已经不为 0,此时继续 acquire(1)操作。这个方法的主要逻辑是:

  1. 通过 tryAcquire 尝试获得独占锁,如果成功返回 true,失败返回 false

  2. 如果 tryAcquire 失败,则会通过 addWaiter 方法将当前线程封装成 Node 添加到 AQS 队列尾部

  3. acquireQueued,将 Node 作为参数通过自旋去尝试获取锁

public final void acquire(int arg) {

	if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg))

		selfInterrupt();

}

NonfairSync.tryAcquire

这个方法的作用是尝试获取锁,如果成功返回 true,不成功返回 false;它是重写 AQS 类中的 tryAcquire 方法,这个方法中调用了 ReentrantLock 类内部类 Sync 中 nonfairTryAcquire 方法

protected final boolean tryAcquire(int acquires) {

	return nonfairTryAcquire(acquires);

}
final boolean nonfairTryAcquire(int acquires) {

	final Thread current = Thread.currentThread(); // 获取当前执行的线程

	int c = getState(); // 获取state的值

	if (c == 0) { // 表示无锁状态

		if (compareAndSetState(0, acquires)) { // cas替换state的值,cas成功表示获取锁成功

			setExclusiveOwnerThread(current); // 保存当前获得锁的线程,下次再来的时候不要再尝试竞争锁

			return true;

		}

	} else if (current == getExclusiveOwnerThread()) { // 如果同一个线程来获得锁,直接增加重入次数

		int nextc = c + acquires;

		if (nextc < 0) // overflow

			throw new Error("Maximum lock count exceeded");

		setState(nextc);

		return true;

	}

	return false;

}

当 tryAcquire 方法获取锁失败以后,则会先调用 addWaiter 将当前线程封装成 Node。入参 mode 表示当前节点的状态,传递的参数是 Node.EXCLUSIVE,表示独占状态,意味着重入锁用到了 AQS 的独占锁功能。

  1. 将当前线程封装成 Node

  2. 当前链表中的 tai'l 节点是否为空,如果不为空则通过 cas 操作把当前线程的 node 添加到 AQS 队列

  3. 如果为空或者 AQS 失败,调用 enq 将节点添加到 AQS 队列

private Node addWaiter(Node mode) {

	Node node = new Node(Thread.currentThread(), mode);

	// Try the fast path of enq; backup to full enq on failure

	Node pred = tail; // tail是AQS中表示同步队列队尾的属性,默认是null

	if (pred != null) { // tail不为空的情况下,说明队列中存在节点

		node.prev = pred; // 把当前线程的Node的prev指向tail

		if (compareAndSetTail(pred, node)) { // 通过cas把当前Node加入到AQS队列

			pred.next = node;

			return node;

		}

	}

	enq(node); // 把node添加到同步队列

	return node;

}

enq 方法就是通过自旋操作把当前节点加入到队列中:

private Node enq(final Node node) {

        for (;;) {

            Node t = tail;

            if (t == null) { // Must initialize

                if (compareAndSetHead(new Node()))

                    tail = head;

            } else {

                node.prev = t;

                if (compareAndSetTail(t, node)) {

                    t.next = node;

                    return t;

                }

            }

        }

}

图解分析

假设 3 个线程来争抢锁,那么截至到 enq 方法运行结束后,或者调用 addWaiter 方法结束后,AQS 中的链表结构图:

AQS.acquireQueued

通过 addWaiter 方法把线程添加到链表后,接着会把 Node 作为参数传递给 acquireQueued 方法,去竞争锁。

  1. 获取当前节点的 prev 节点

  2. 如果 prev 节点为 head 节点,那么就有资格去竞争锁,调用 tryAcquire 去抢占锁

  3. 抢占锁成功后,把获得锁的节点设置成 head,并且移除原来的初始化 head 节点

  4. 如果获取锁失败,则根据 waitStatus 决定是否需要挂起线程

  5. 最后,通过 cancelAcquire 取消获得锁的操作

final boolean acquireQueued(final Node node, int arg) {

        boolean failed = true;

        try {

            boolean interrupted = false;

            for (;;) {

                final Node p = node.predecessor(); // 获取当前节点的prev节点

                if (p == head && tryAcquire(arg)) { // 如果prev节点为head,说明有资格去争抢锁

                    setHead(node); // 获取锁成功,也就是ThreadA释放了锁,然后设置head为ThreadB

                    p.next = null; // help GC 把原head节点从链表中删除

                    failed = false;

                    return interrupted;

                }

                // 线程A可能还没释放锁,使得ThreadB在执行tryAcquire时返回false

                if (shouldParkAfterFailedAcquire(p, node) &&

                    parkAndCheckInterrupt())

                    interrupted = true;

            }

        } finally {

            if (failed)

                cancelAcquire(node);

        }

    }

 

shouldParkAfterFailedAcquire

如果 ThreadA 的锁还没有被释放的情况下,ThreadB 和 Thread C 来争抢锁肯定是会失败的,那么失败以后会调用 shouldParkAfterFailedAcquire 方法;Node 中有 5 中状态,分别是 CANCELLED(1)、SIGNAL(-1)、CONDITION(-2)、PROPAGATE(-3)、默认状态(0)

CANCELLED: 在同步队列中等待的线程等待超时或者被中断,需要从同步队列中取消该 Node 节点,其节点的 waitStatus 为 CANCELLED,即结束状态,进入该状态后的节点将不会再变化

SIGNAL:只要前置节点释放锁,就会通知标识为 SIGNAL 状态的后续节点的线程

CONDITION:和 Condition 有关

PROPAGATE:共享模式下,PROPAGATE 状态的线程处于可运行状态

这个方法的作用是通过 Node 的状态的判断,Thread A 竞争锁失败后是否被挂起。

  1. 如果 Thread A 的 prev 节点状态为 SIGNAL,那就表示可以放心挂起当前线程

  2. 通过循环扫描链表把 CANCELLED 状态的节点移除

  3. 修改 prev 节点的状态为 SIGNAL,返回 false

返回 false 时,也就是不需要挂起;返回 true,则需要调用 parkAndCheckInterrupt 挂起当前线程

private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
        int ws = pred.waitStatus;
        if (ws == Node.SIGNAL)
            /*
             * This node has already set status asking a release
             * to signal it, so it can safely park.
             */
            return true;
        if (ws > 0) {
            /*
             * Predecessor was cancelled. Skip over predecessors and
             * indicate retry.
             */
            do {
                node.prev = pred = pred.prev;
            } while (pred.waitStatus > 0);
            pred.next = node;
        } else {
            /*
             * waitStatus must be 0 or PROPAGATE.  Indicate that we
             * need a signal, but don't park yet.  Caller will need to
             * retry to make sure it cannot acquire before parking.
             */
            compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
        }
        return false;
    }

版权声明:本文内容由互联网用户自发贡献,该文观点与技术仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 dio@foxmail.com 举报,一经查实,本站将立刻删除。

相关推荐


学习编程是顺着互联网的发展潮流,是一件好事。新手如何学习编程?其实不难,不过在学习编程之前你得先了解你的目的是什么?这个很重要,因为目的决定你的发展方向、决定你的发展速度。
IT行业是什么工作做什么?IT行业的工作有:产品策划类、页面设计类、前端与移动、开发与测试、营销推广类、数据运营类、运营维护类、游戏相关类等,根据不同的分类下面有细分了不同的岗位。
女生学Java好就业吗?女生适合学Java编程吗?目前有不少女生学习Java开发,但要结合自身的情况,先了解自己适不适合去学习Java,不要盲目的选择不适合自己的Java培训班进行学习。只要肯下功夫钻研,多看、多想、多练
Can’t connect to local MySQL server through socket \'/var/lib/mysql/mysql.sock问题 1.进入mysql路径
oracle基本命令 一、登录操作 1.管理员登录 # 管理员登录 sqlplus / as sysdba 2.普通用户登录
一、背景 因为项目中需要通北京网络,所以需要连vpn,但是服务器有时候会断掉,所以写个shell脚本每五分钟去判断是否连接,于是就有下面的shell脚本。
BETWEEN 操作符选取介于两个值之间的数据范围内的值。这些值可以是数值、文本或者日期。
假如你已经使用过苹果开发者中心上架app,你肯定知道在苹果开发者中心的web界面,无法直接提交ipa文件,而是需要使用第三方工具,将ipa文件上传到构建版本,开...
下面的 SQL 语句指定了两个别名,一个是 name 列的别名,一个是 country 列的别名。**提示:**如果列名称包含空格,要求使用双引号或方括号:
在使用H5混合开发的app打包后,需要将ipa文件上传到appstore进行发布,就需要去苹果开发者中心进行发布。​
+----+--------------+---------------------------+-------+---------+
数组的声明并不是声明一个个单独的变量,比如 number0、number1、...、number99,而是声明一个数组变量,比如 numbers,然后使用 nu...
第一步:到appuploader官网下载辅助工具和iCloud驱动,使用前面创建的AppID登录。
如需删除表中的列,请使用下面的语法(请注意,某些数据库系统不允许这种在数据库表中删除列的方式):
前不久在制作win11pe,制作了一版,1.26GB,太大了,不满意,想再裁剪下,发现这次dism mount正常,commit或discard巨慢,以前都很快...
赛门铁克各个版本概览:https://knowledge.broadcom.com/external/article?legacyId=tech163829
实测Python 3.6.6用pip 21.3.1,再高就报错了,Python 3.10.7用pip 22.3.1是可以的
Broadcom Corporation (博通公司,股票代号AVGO)是全球领先的有线和无线通信半导体公司。其产品实现向家庭、 办公室和移动环境以及在这些环境...
发现个问题,server2016上安装了c4d这些版本,低版本的正常显示窗格,但红色圈出的高版本c4d打开后不显示窗格,
TAT:https://cloud.tencent.com/document/product/1340