Chrome - Threading

本文档详细介绍了Chromium浏览器的多线程架构设计,包括各个线程的作用、如何实现线程间的通信以及如何保持浏览器响应性等。此外,还介绍了如何在不同线程间传递任务、使用异步API及取消任务的方法。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

http://www.chromium.org/developers/design-documents/threading

 

Overview

Chromium is a very multithreaded product. We try to keep the UI as responsive as possible, and this means not blocking the UI thread with any blocking I/O or other expensive operations. Our approach is to use message passing as the way of communicating between threads. We discourage locking and threadsafe objects. Instead, objects live on only one thread, we pass messages between threads for communication, and we use callback interfaces (implemented by message passing) for most cross-thread requests.

The Thread object is defined in base/threading/thread.h. In general you should probably use one of the existing threads described below rather than make new ones. We already have a lot of threads that are difficult to keep track of. Each thread has a MessageLoop (see base/message_loop/message_loop.h) that processes messages for that thread. You can get the message loop for a thread using the Thread.message_loop() function.

Existing threads

Most threads are managed by the BrowserProcess object, which acts as the service manager for the main "browser" process. By default, everything happens on the UI thread. We have pushed certain classes of processing into these other threads. It has getters for the following threads:

  • ui_thread: Main thread where the application starts up.
  • io_thread: This thread is somewhat mis-named. It is the dispatcher thread that handles communication between the browser process and all the sub-processes. It is also where all resource requests (web page loads) are dispatched from (see Multi-process Architecture).
  • file_thread: A general process thread for file operations. When you want to do blocking filesystem operations (for example, requesting an icon for a file type, or writing downloaded files to disk), dispatch to this thread.
  • db_thread: A thread for database operations. For example, the cookie service does sqlite operations on this thread. Note that the history database doesn't use this thread yet.
  • safe_browsing_thread

Several components have their own threads:

  • History: The history service object has its own thread. This might be merged with the db_thread above. However, we need to be sure that things happen in the correct order -- for example, that cookies are loaded before history since cookies are needed for the first load, and history initialization is long and will block it.
  • Proxy service: See net/http/http_proxy_service.cc.
  • Automation proxy: This thread is used to communicate with the UI test program driving the app.

Keeping the browser responsive

As hinted in the overview, we avoid doing any blocking I/O on the UI thread to keep the UI responsive.  Less apparent is that we also need to avoid blocking I/O on the IO thread.  The reason is that if we block it for an expensive operation, say disk access, then IPC messages don't get processed.  The effect is that the user can't interact with a page.  Note that asynchronous/overlapped IO are fine.

Another thing to watch out for is to not block threads on one another.  Locks should only be used to swap in a shared data structure that can be accessed on multiple threads.  If one thread updates it based on expensive computation or through disk access, then that slow work should be done without holding on to the lock.  Only when the result is available should the lock be used to swap in the new data.  An example of this is in PluginList::LoadPlugins (src/webkit//plugins/npapi/plugin_list.cc). If you must use locks, here are some best practices and pitfalls to avoid.

In order to write non-blocking code, many APIs in Chromium are asynchronous. Usually this means that they either need to be executed on a particular thread and will return results via a custom delegate interface, or they take a base::Callback<> object that is called when the requested operation is completed.  Executing work on a specific thread is covered in the PostTask section below.

Getting stuff to other threads

base::Callback<>, Async APIs, and Currying

A base::Callback<> (see the docs in callback.h ) is templated class with a Run() method.  It is a generalization of a function pointer and is created by a call to base::Bind.  Async APIs often will take a base::Callback<> as a means to asynchronously return the  results of an operation.  Here is an example of a hypothetical FileRead API.
void ReadToString(const std::string& filename, const base::Callback<void(const std::string&)>& on_read);

void DisplayString(const std::string& result) {
  LOG(INFO) << result;
}

void SomeFunc(const std::string& file) {
  ReadToString(file, base::Bind(&DisplayString));
};
In the example above, base::Bind takes the function pointer &DisplayString and turns it into a base::Callback<void(const std::string& result)>. The type of the generated base::Callback<> is inferred from the arguments.  Why not just pass the function pointer directly?  The reason is base::Bind allows the caller to adapt function interfaces and/or attach extra context via Currying ( http://en.wikipedia.org/wiki/Currying ).  For instance, if we had a utility function DisplayStringWithPrefix that took an extra argument with the prefix, we use base::Bind to adapt the interface as follows.
void DisplayStringWithPrefix(const std::string& prefix, const std::string& result) {
  LOG(INFO) << prefix << result;
}

void AnotherFunc(const std::string& file) {
  ReadToString(file, base::Bind(&DisplayStringWithPrefix, "MyPrefix: "));
};
This can be used in lieu of creating an adapter functions a small classes that holds prefix as a member variable.  Notice also that the "MyPrefix: " argument is actually a const char*, while DisplayStringWithPrefix actually wants a const std::string&.  Like normal function dispatch, base::Bind, will coerce parameters types if possible.  See "How arguments are handled by base::Bind()" below for more details about argument storage, copying, and special handling of references.

The lowest level of dispatching to another thread is to use the MessageLoop.PostTask and MessageLoop.PostDelayedTask (see base/message_loop/message_loop.h). PostTask schedules a task to be run on a particular thread.  A task is defined as a base::Closure, which is a typedef for a base::Callback<void(void)>. PostDelayedTask schedules a task to be run after a delay on a particular thread. A task is represented by the base::Closure typedef, which contains a Run() function, and is created by calling base::Bind().  To process a task, the message loop eventually calls base::Closure's Run function, and then drops the reference to the task object.  Both PostTask and PostDelayedTask take a tracked_objects::Location parameter, which is used for lightweight debugging purposes (counts and primitive profiling of pending and completed tasks can be monitored in a debug build via the url about:objects).  Generally the macro value FROM_HERE is the appropriate value to use in this parameter.

Note that new tasks go on the message loop's queue, and any delay that is specified is subject to the operating system's timer resolutions. This means that under Windows, very small timeouts (under 10ms) will likely not be honored (and will be longer).  Using a timeout of 0 in PostDelayedTask is equivalent to calling PostTask, and adds no delay beyond queuing delay. PostTask is also used to do something on the current thread "sometime after the current processing returns to the message loop."  Such a continuation on the current thread can be used to assure that other time critical tasks are not starved on this thread.

The following is an example of a creating a task for a function and posting it to another thread (in this example, the file thread):

void WriteToFile(const std::string& filename, const std::string& data);
BrowserThread::PostTask(BrowserThread::FILE, FROM_HERE,
                        base::Bind(&WriteToFile, "foo.txt", "hello world!"));
You should always use BrowserThread to post tasks between threads.  Never cache MessageLoop pointers as it can cause bugs such as the pointers being deleted while you're still holding on to them.  More information can be found here.

base::Bind() and class methods.

The base::Bind() API also supports invoking class methods as well.  The syntax is very similar to calling base::Bind() on a function, except the first argument should be the object the method belongs to. By default, the object that PostTask uses must be a thread-safe reference-counted object. Reference counting ensures that the object invoked on another thread will stay alive until the task completes.

class MyObject : public base::RefCountedThreadSafe<MyObject> {
 public:
  void DoSomething(const std::string16& name) {
    thread_->message_loop()->PostTask(
       FROM_HERE, base::Bind(&MyObject::DoSomethingOnAnotherThread, this, name));
  }

  void DoSomethingOnAnotherThread(const std::string16& name) {
    ...
  }
 private:
  // Always good form to make the destructor private so that only RefCountedThreadSafe can access it.
  // This avoids bugs with double deletes.
  friend class base::RefCountedThreadSafe<MyObject>;

  ~MyObject();
  Thread* thread_;
};
If you have external synchronization structures that can completely insure that an object will always be alive while the task is waiting to execute, you can wrap the object pointer with base::Unretained() when calling base::Bind() to disable the refcounting.  This will also allow using base::Bind() on classes that are not refcounted.  Be careful when doing this!

How arguments are handled by base::Bind().

The arguments given to base::Bind() are copied into an internal InvokerStorage structure object (defined in base/bind_internal.h). When the function is finally executed, it will see copies of the arguments.  This is important if your target function or method takes a const reference; the reference will be to a copy of the argument.  If you need a reference to the original argument, you can wrap the argument with base::ConstRef().  Use this carefully as it is likely dangerous if target of the reference cannot be guaranteed to live past when the task is executed.  In particular, it is almost never safe to use base::ConstRef() to a variable on the stack unless you can guarantee the stack frame will not be invalidated until the asynchronous task finishes.

Sometimes, you will want to pass reference-counted objects as parameters (be sure to use RefCountedThreadSafe and not plain RefCounted as the base class for these objects). To ensure that the object lives throughout the entire request, the Closure generated by base::Bind must keep a reference to it. This can be done by passing scoped_refptr as the parameter type, or by wrapping the raw pointer with make_scoped_refptr():

class SomeParamObject : public base::RefCountedThreadSafe<SomeParamObject> {
 ...
};

class MyObject : public base::RefCountedThreadSafe<MyObject> {
 public:
  void DoSomething() {
    scoped_refptr<SomeParamObject> param(new SomeParamObject);
    thread_->message_loop()->PostTask(FROM_HERE
       base::Bind(&MyObject::DoSomethingOnAnotherThread, this, param));
  }
  void DoSomething2() {
    SomeParamObject* param = new SomeParamObject;
    thread_->message_loop()->PostTask(FROM_HERE
       base::Bind(&MyObject::DoSomethingOnAnotherThread, this, 
                         make_scoped_refptr(param)));
  }
  // Note how this takes a raw pointer. The important part is that
  // base::Bind() was passed a scoped_refptr; using a scoped_refptr
  // here would result in an extra AddRef()/Release() pair.
  void DoSomethingOnAnotherThread(SomeParamObject* param) {
...
}
};

If you  want to pass the object without taking a reference on it, wrap the argument with base::Unretained(). Again, using this means there are external guarantees on the lifetime of the object, so tread carefully!

If your object has a non-trivial destructor that needs to run on a specific thread, you can use the following trait. This is needed since timing races could lead to a task completing execution before the code that posted it has unwound the stack.

class MyObject : public base::RefCountedThreadSafe<MyObject, BrowserThread::DeleteOnIOThread> {

Callback cancellation

There are 2 major reasons to cancel a task (in the form of a Callback):

  • You want to do something later on your object, but at the time your callback runs, your object may have been destroyed.
  • When input changes (e.g. user input), old tasks become unnecessary. For performance consideration, you should cancel them.
See following about different approaches for cancellation.

It 's dangerous to cancel a task with owned parameters. See following example. (The example uses base::WeakPtr for cancellation, but the problem applies to all approaches).

class MyClass {
 public:
  // Owns |p|.
  void DoSomething(AnotherClass* p) {
    ...
  }
  WeakPtr<MyClass> AsWeakPtr() {
    return weak_factory_.GetWeakPtr();
  }
 private:
  base::WeakPtrFactory<MyObject> weak_factory_;
};

...
Closure cancelable_closure = Bind(&MyClass::DoSomething, object->AsWeakPtr(), p);
Callback<void(AnotherClass*)> cancelable_callback = Bind(&MyClass::DoSomething, object->AsWeakPtr());
...

void FunctionRunLater(const Closure& cancelable_closure,
                      const Callback<void(AnotherClass*)>& cancelable_callback) {
  ...
  // Leak memory!
  cancelable_closure.Run();
  cancelable_callback.Run(p);
}
In FunctionRunLater, both Run() calls will leak p when object is already destructed. Using scoped_ptr can fix the bug.
class MyClass {
 public:
  void DoSomething(scoped_ptr<AnotherClass> p) {
    ...
  }
  ...
};

base::WeakPtr and Cancellation [NOT THREAD SAFE]

You can use a base::WeakPtr and base::WeakPtrFactory (in base/memory/weak_ptr.h) to ensure that any invokes can not outlive the object they are being invoked on, without using reference counting. The base::Bind mechanism has special understanding for base::WeakPtr that will disable the task's execution if the base::WeakPtr has been invalidated. The base::WeakPtrFactory object can be used to generate base::WeakPtr instances that know about the factory object. When the factory is destroyed, all the base::WeakPtr will have their internal "invalidated" flag set, which will make any tasks bound to them to not dispatch. By putting the factory as a member of the object being dispatched to, you can get automatic cancellation.

NOTE: This only works when the task is posted to the same thread. Currently there is not a general solution that works for tasks posted to other threads. See the next section about CancelableTaskTracker for an alternative solution.

class MyObject {
 public:
  MyObject() : weak_factory_(this) {}

  void DoSomething() {
    const int kDelayMS = 100;
    MessageLoop::current()->PostDelayedTask(FROM_HERE,
        base::Bind(&MyObject::DoSomethingLater, weak_factory_.GetWeakPtr()),
        kDelayMS);
  }

  void DoSomethingLater() {
    ...
  }

 private:
  base::WeakPtrFactory<MyObject> weak_factory_;
};

CancelableTaskTracker

While base::WeakPtr is very helpful to cancel a task, it is not thread safe so can not be used to cancel tasks running on another thread. This is sometimes a performance critical requirement. E.g. We need to cancel database lookup task on DB thread when user changes inputed text. In this kind of situation CancelableTaskTracker is appropriate.


With CancelableTaskTracker you can cancel a single task with returned TaskId. This is another reason to use CancelableTaskTracker instead of base::WeakPtr, even in a single thread context.

CancelableTaskTracker has 2 Post methods doing the same thing as the ones in base::TaskRunner, with additional cancellation support.
class UserInputHandler : public base::RefCountedThreadSafe<UserInputHandler> {
  // Runs on UI thread.
  void OnUserInput(Input input) {
    CancelPreviousTask();
    DBResult* result = new DBResult();
    task_id_ = tracker_->PostTaskAndReply(
        BrowserThread::GetMessageLoopProxyForThread(BrowserThread::DB),
        FROM_HERE,
        base::Bind(&LookupHistoryOnDBThread, this, input, result),
        base::Bind(&ShowHistoryOnUIThread, this, base::Owned(result)));
  }

  void CancelPreviousTask() {
    tracker_->TryCancel(task_id_);
  }

  ...

 private:
  CancelableTaskTracker tracker_;  // Cancels all pending tasks while destruction.
  CancelableTaskTracker::TaskId task_id_;
  ...
};
Since task runs on other threads, there's no guarantee it can be successfully canceled.
When TryCancel() is called:
  • If neither task nor reply has started running, both will be canceled.
  • If task is already running or has finished running, reply will be canceled.
  • If reply is running or has finished running, cancelation is a noop.
Like base::WeakPtrFactory, CancelableTaskTracker will cancel all tasks on destruction.

Cancelable request (DEPRECATED)

Note. Cancelable request is deprecated. Please do not use it in new code. For canceling tasks running on the same thread, use WeakPtr. For canceling tasks running on a different thread, use CancelableTaskTracker.

A cancelable request makes it easier to make requests to another thread with that thread returning some data to you asynchronously. Like the revokable store system, it uses objects that track whether the originating object is alive. When the calling object is deleted, the request will be canceled to prevent invalid callbacks.

Like the revokable store system, a user of a cancelable request has an object (here, called a "Consumer") that tracks whether it is alive and will auto-cancel any outstanding requests on deleting.

class MyClass {
  void MakeRequest() {
    frontend_service->StartRequest(some_input1, some_input2, this,
        // Use base::Unretained(this) if this may cause a refcount cycle.
        base::Bind(&MyClass:RequestComplete, this));  
  }
  void RequestComplete(int status) {
    ...
  }

 private:
  CancelableRequestConsumer consumer_;
};

Note that the MyClass::RequestComplete, is bounded with base::Unretained(this) here.

The consumer also allows you to associate extra data with a request. Use CancelableRequestConsumer which will allow you to associate arbitrary data with the handle returned by the provider service when you invoke the request. The data will be automatically destroyed when the request is canceled.

A service handling requests inherits from CancelableRequestProvider. This object provides methods for canceling in-flight requests, and will work with the consumers to make sure everything is cleaned up properly on cancel. This frontend service just tracks the request and sends it to a backend service on another thread for actual processing. It would look like this:

class FrontendService : public CancelableRequestProvider {
  typedef base::Callback<void(int)> RequestCallbackType;

  Handle StartRequest(int some_input1, int some_input2,
      CallbackConsumer* consumer,
      const RequestCallbackType& callback) {
    scoped_refptr< CancelableRequest<FrontendService::RequestCallbackType> >
        request(new CancelableRequest(callback));
    AddRequest(request, consumer);

    // Send the parameters and the request to the backend thread.
    backend_thread_->PostTask(FROM_HERE,
        base::Bind(&BackendService::DoRequest, backend_, request,
                   some_input1, some_input2), 0);    
    // The handle will have been set by AddRequest.
    return request->handle();
  }
};

The backend service runs on another thread. It does processing and forwards the result back to the original caller. It would look like this:

class BackendService : public base::RefCountedThreadSafe<BackendService> {
  void DoRequest(
      scoped_refptr< CancelableRequest<FrontendService::RequestCallbackType> >
          request,
      int some_input1, int some_input2) {
    if (request->canceled())
      return;

    ... do your processing ...

    // Execute ForwardResult() like you would do Run() on the base::Callback<>.
    request->ForwardResult(return_value);
  }
};

 

<think>我们注意到用户的问题是关于如何使用threading关闭当前浏览器标签页。然而,根据提供的引用和一般知识,关闭浏览器标签页通常是通过浏览器提供的JavaScript API来实现的,而不是通过Python的threading模块。threading模块用于在Python中创建和管理线程,通常用于在同一个程序中并发执行多个任务。它不能直接控制浏览器标签页。 根据引用[1]、[2]、[3]、[4]: - 引用[1]和[2]提到了在浏览器设置中关闭标签页的方法,以及如何恢复关闭的标签页。 - 引用[3]展示了在Vue.js框架中如何通过操作路由和状态管理来关闭当前标签页(实际上是关闭标签页的视图,并跳转到其他页面)。 - 引用[4]提到了在IE和Firefox中通过JavaScript关闭当前标签页的方法,但需要浏览器配置允许脚本关闭窗口。 因此,关闭浏览器标签页通常是通过JavaScript代码`window.close()`实现的。但是,这个操作受到浏览器的安全限制,通常只能关闭由JavaScript打开的窗口(通过`window.open()`打开的)或者在某些浏览器中需要用户配置允许脚本关闭窗口。 用户询问的是如何使用Python的threading模块来关闭当前浏览器标签页。这似乎是一个跨领域的问题,因为Python程序(包括使用threading)通常运行在浏览器之外,不能直接控制浏览器标签页,除非通过浏览器自动化工具(如Selenium)或与浏览器扩展进行交互。 因此,我们可以推断用户可能想要在浏览器自动化测试中使用多线程来控制浏览器标签页的关闭。以下是一个使用Selenium和threading关闭浏览器标签页的示例: 步骤: 1. 使用Selenium WebDriver打开浏览器并控制浏览器。 2. 在一个线程中执行关闭当前标签页的操作。 注意:直接关闭当前标签页可能会导致浏览器窗口关闭(如果只有一个标签页),通常我们使用`driver.close()`来关闭当前标签页(如果是多个标签页,则关闭当前标签页,但不会关闭整个浏览器),使用`driver.quit()`关闭整个浏览器。 但是,请注意,Selenium的每个WebDriver实例通常对应一个浏览器窗口,而多线程操作WebDriver需要谨慎,因为WebDriver本身不是线程安全的。通常建议每个线程使用独立的WebDriver实例。 然而,用户的问题是如何关闭“当前”标签页,所以我们假设用户已经打开了多个标签页,并且想要关闭当前聚焦的标签页。 示例代码(使用Selenium和threading): ```python from selenium import webdriver from selenium.webdriver.common.by import By import threading # 定义一个函数用于关闭当前标签页 def close_current_tab(driver): # 确保driver不为空 if driver: # 关闭当前标签页 driver.close() # 主程序 if __name__ == "__main__": # 启动浏览器(这里以Chrome为例) driver = webdriver.Chrome() driver.get("https://www.example.com") # 打开一个新标签页 driver.execute_script("window.open('https://www.example2.com', '_blank');") # 切换到新标签页 driver.switch_to.window(driver.window_handles[1]) # 现在,我们创建一个线程来关闭当前标签页(即第二个标签页) t = threading.Thread(target=close_current_tab, args=(driver,)) t.start() t.join() # 等待线程结束 # 注意:由于关闭标签页后,driver的window_handles会减少,所以我们需要切换回第一个标签页 if len(driver.window_handles) > 0: driver.switch_to.window(driver.window_handles[0]) # 做一些其他操作... # 最后关闭整个浏览器 driver.quit() ``` 重要提示:在多线程环境中使用同一个WebDriver实例是不安全的,因为WebDriver的命令不是线程安全的。上述示例仅为了演示,实际中应该避免多个线程同时操作同一个driver实例。更好的做法是,每个线程使用独立的driver实例(即独立的浏览器窗口)。 如果用户不是在做浏览器自动化测试,而是想要在自己的Python程序中控制浏览器标签页,那么可能需要使用浏览器提供的远程调试接口(如Chrome DevTools Protocol)或者浏览器扩展程序,但这通常比较复杂。 因此,根据用户的问题,我们假设用户是在做自动化测试,所以给出了上述使用Selenium和threading的示例。 另外,根据引用[3],在Vue.js中关闭标签页是通过路由操作实现的,这实际上并不是真正关闭浏览器的标签页,而是关闭了在单页应用(SPA)中模拟的标签页(通常是前端路由的视图)。所以,如果用户是在开发Vue.js应用,那么应该参考引用[3]的方法。 总结:使用threading模块本身并不能直接关闭浏览器标签页,必须结合浏览器自动化工具(如Selenium)或浏览器提供的API(如JavaScript)来实现。 因此,我们回答用户:要使用threading关闭当前浏览器标签页,通常需要结合浏览器自动化工具,如Selenium。请注意线程安全和浏览器兼容性问题。 生成相关问题: 1. 如何安全地在多线程中使用Selenium WebDriver? 2. 除了Selenium,还有哪些方法可以控制浏览器标签页? 3. 如何通过JavaScript关闭当前浏览器标签页? 4. 在Vue.js项目中如何关闭当前标签页视图?</think>### 如何使用 threading 关闭当前浏览器标签页? 关闭浏览器标签页通常需要在前端 JavaScript 中实现,而非直接通过 Python 的 `threading` 模块操作。因为浏览器运行在客户端环境,而 Python 的 `threading` 主要用于服务端或本地应用的多线程管理。以下是两种可行的实现方案: --- ### 方案 1:通过 JavaScript 关闭标签页(推荐) 浏览器原生提供 `window.close()` 方法关闭当前标签页,但需满足安全策略: ```javascript // JavaScript 代码 function closeCurrentTab() { window.close(); // 关闭当前标签页 } ``` **限制**: - 只能关闭由 `window.open()` 打开的标签页,或用户明确允许的页面[^4]。 -Chrome/Firefox 中需配置 `dom.allow_script_to_close_windows=true`[^4]。 --- ### 方案 2:结合 Python 后端与前端通信(间接实现) 若需通过 Python 触发关闭操作,需建立前后端通信链路: 1. **Python 后端**(使用 Flask + threading): ```python from flask import Flask, jsonify import threading app = Flask(__name__) close_requested = False @app.route('/trigger-close') def trigger_close(): global close_requested close_requested = True return jsonify({"status": "close signal sent"}) def background_task(): # 其他后台任务... pass if __name__ == '__main__': thread = threading.Thread(target=background_task) thread.start() app.run() ``` 2. **前端轮询或 WebSocket**: ```javascript // 前端定期检查关闭信号 setInterval(() => { fetch('/trigger-close') .then(res => res.json()) .then(data => { if (data.status === "close signal sent") { window.close(); // 收到信号后关闭标签页 } }); }, 1000); ``` --- ### 关键注意事项 1. **浏览器安全限制**: 现代浏览器禁止未经用户交互的 `window.close()`,需通过按钮点击等事件触发[^4]: ```html <button onclick="window.close()">关闭标签页</button> ``` 2. **多线程作用**: Python 的 `threading` 在此场景中仅用于管理后端任务(如信号触发),无法直接操控浏览器。 --- ### 相关问题 1. 如何绕过浏览器限制强制关闭标签页? 2. 在 Vue/React 中如何安全关闭当前路由标签页?[^3] 3. 浏览器扩展如何实现标签页管理? 4. 多线程 Python 后端如何与前端实时通信? [^1]: 360 浏览器关闭新建标签页的方法 [^2]: 谷歌浏览器恢复关闭标签页的技巧 [^3]: Vue.js 中关闭标签页的路由操作示例 [^4]: 浏览器 `window.close()` 的安全限制说明
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值