【muduo源码学习】源码分析之Channel、EventLoop和Selector

在 one-loop-per-thread核心原理 中,介绍了 one loop per thread 网络模型的核心原理,在本篇本章中,我将重点介绍该模型中的IO事件处理部分在 muduo 网络库中是如何实现的,而涉及 TCP 连接处理部分,也即 socket 编程部分,将在另外的博客中进行讨论。

先以单线程下的 one loop per thread 为例,看看 muduo 中是如何设计实现的,然后再转向多线程下的 one loop per thread。
在这里插入图片描述

对于单线程下的 one loop per thread 模型,主要由 EventLoop、Channel 以及 Selector(在muduo的实现中,为 Poller)三个类来实现。

  • EventLoop。EventLoop类是对事件循环的抽象和表示。对于单线程下的事件循环而言,如上图所示,一个事件循环主要负责以下两件事。(注意:我们应该将事件的概念抽象化,在本文中所指的事件应该是抽象层面的事件,而不是具体的事件,例如socket上的读写事件、timerfd的读事件,或者是普通文件描述符的读写事件)
    • 管理文件描述符上(socket、timerfd等)的读写事件。对于上面的这幅图,我们需要管理监听socket的读事件和已连接socket的读写事件。这部分由 Channel 类负责,一个 EventLoop 实例对象包含一个 Channel 数组成员。
    • 调用 IO multiplexing 函数,监听被当前IO线程所管理的文件描述符上的读写事件。这部分由 Selector 类负责。
  • Channel。Channel类负责管理具体的文件描述符上的读写事件,以及读写事件到来时对应的回调函数。
  • Selector。IO multiplexing 的抽象类,具体的实现由其子类继承实现。目前我实现的版本中,只实现了 Linux 下的 epoll IO multiplexing 函数,对应的实现类为 Epoll。一个EventLoop实例对象只拥有一个Selector的实例对象,EventLoop通过其Selector成员变量来管理多个Channel。

下面依次介绍 Channel、Selector 和 EventLoop 三个类的实现。

Channel

Channel类包含的成员变量如下:

class Channel {
// ...
public:
	enum class EventState {
		NEW,
		ADDED,
		DELETED
	};

	using WriteEventCallback = std::function<void()>;
	using ReadEventCallback = std::function<void()>;
	using ErrorEventCallback = std::function<void()>;
	using CloseEventCallback = std::function<void()>;
	
private:
	EventLoop *loop_;
	const int fd_;

	int requestedEvents_; // 关注的事件类型
	int returnedEvents_;  // 发生的事件类型

	EventState eventState_; 

	WriteEventCallback writeEventCallback_;
	ReadEventCallback readEventCallback_;
	ErrorEventCallback errorEventCallback_;
	CloseEventCallback closeEventCallback_;

	static const int kNoneEvent;      // kNoneEvent = 0
	static const int kReadEvent;      // kReadEvent = POLLIN | POLLPRI
	static const int kWriteEvent;     // kWriteEvent = POLLOUT
};

Channel类中每个成员变量的作用:

  • EventLoop *loop_ 。EventLoop 和 Channel 是一对多的关系,一个EventLoop实例对象通过其Selector成员变量来间接管理多个Channel实例对象。一个Channel实例对象通过包含其所属的EventLoop实例对象的指针来保持这对应关系。
  • const int fd_。Channel实例对象管理的事件所属的文件描述符。这里需要注意,Channel只负责管理文件描述符上的需要关注哪些事件,以及关注的事件发生后调用对应的回调函数。而对于这些事件所属文件描述符的生命周期,不用关心,即文件描述符什么时候需要调用 close(fd_) 关闭,Channel不负责任。至于具体的文件描述符的生命周期,由不同的类来管理,这里不进行展开。
  • int requestedEvents_int returnedEvents_requestedEvents_ 表示 IO multiplexing 函数关注文件描述符上的事件类型,成员函数 enableRead()enableWrite() 设置该变量的值。returnedEvents_ 表示 IO multiplexing 检测到有事件到来时,到来的事件类型是什么。
  • EventState eventState_。表示文件描述符与IO multiplexing之间的状态关系。即 IO multiplexing 函数是否将检测文件描述符上的事件。以 epoll 为例,NEW 表示 fd 未添加到 epollfd 中,时 Channel 所指文件描述符的初始状态;ADDED 表示以添加到 epollfd 中,DELETED 表示已从 epollfd 中删除。
  • xxxCallback 表示对应事件的回调函数,一般在Channel实例对象初始化后由对应的成员函数设置。
  • 三个静态成员变量,用于表示事件类型。因为本网络库是在linux下开发,在linux下,poll和epoll中的读写宏定义中,其值相等。因此使用了poll中的宏定义表示可读可写,更好的做法是,自定义一个类来封装poll和epoll的底层事件类型,做到对外统一,后续对这部分进行改进。

结合 Channel 类中每个成员变量的作用弄清 Channel 类的职责后,其成员函数的实现就很好理解了,这里就不在一一罗列。

Selector

Selector类是IO复用函数的抽象类,定义了实现的接口。目前本库只实现了linux下的epoll IO 复用函数,其实现类为 Epoll。Selector类和Epoll类包含的成员变量如下:

class Selector: public noncopyable
{
// ...
protected:
	// key为fd,即Channel中的fd_成员变量
	using ChannelMap = std::map<int, Channel*>;
	ChannelMap channelMap_;

private:
	EventLoop* loop_;
};

class Epoll: public Selector
{
// ...
private:
	using EventVector = std::vector<struct epoll_event>;
	EventVector events_;
	const int epollFd_;        
	static const int InitEventVectorSize = 16;

};

Selector类中每个成员变量的作用:

  • EventLoop* loop_ 。事件循环是基于 IO multiplexing 函数来实现的,EventLoop 和 Selector 是一一对应的,即一个 EventLoop 实例对象拥有一个 Selector 成员对象;反过来,一个 Selector 成员实例拥有包含它的EventLoop指针。
  • ChannelMap channelMap_。map类型,用来保存 Selector 监控了哪些文件描述符,key为文件描述符,value为对应的 Channel 实例对象的指针。需要注意,map中的value为指针类型,即Selector不负责Channel实例对象的生命周期。

Epoll类继承自Selector,其每个成员变量的作用为:

  • const int epollFd_。epoll实例对应的文件描述符。
  • EventVector events_。用于接收 epoll_wait 从内核拷贝回用户态的 epoll_event 数组。
  • static const int InitEventVectorSize = 16 。表示初始时 event_ 的数组大小。

Epoll类的实现较为简单,其成员函数 select 是对 epoll_wait 的封装,updateChannel 是对 epoll_ctl 的封装,这里不粘贴出具体的实现细节。

这里举一个代码示例来帮助理解 Channel 类是如何和 Selector 类协作实现文件描述符的事件管理的。以使用 epoll IO 复用函数为例,使用 epoll 管理一个文件描述符上的读事件的一般写法如下:

// 省略文件描述符 fd 的创建/获取
// 省略 epoll 文件描述符 epollfd 的创建/获取

struct epoll_event event;
event.data.fd = fd;
event.events = EPOLLIN;
epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, &event);

// ...
struct epoll_event events[EVENT_NUMBER];
int event_numbers = epoll_wait(epollfd, events, EVENT_NUMBER, -1);
for (int i = 0; i < event_numbers; ++i) {
	// 根据 events[i].events 的值来判断事件类型
	if (events[i].events == EPOLLIN) {
		// 处理读事件
	}
}

使用 Channel 和 Selector 封装后,上述代码等价如下:

// 创建一个 Epoll 对象,初始化过程中会调用 epoll_create 创建一个 epollfd 文件描述符
Selector selector = new Epoll(loop);

// 创建一个 Channel 实例对象,管理 fd 上的读写事件
Channel channel(loop, fd);

// 调用 enable() 方法,其内部封装了 `epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, &event)` 的实现
/*
enableRead 的内部实现等价于:
struct epoll_event event;
event.events = channel.events();
event.data.ptr = &channel;
*/
channel.enableRead();

/*
	其内部实现为:
	int num = epoll_wait();
	for (int i = 0; i < num; ++i) {
		Channel* channel = events[i].data.ptr;
		channel->handEvents();
	}
*/
selector.select();

在网络编程中,我们关注文件描述符上的读、写事件,异常行为可以通过读文件描述符来捕获,当读或写事件发生时,根据文件描述符的类型执行对应的操作。除了读、写事件发生后,执行的读写操作不同外,使用 IO multiplexing 函数管理、监听文件描述符可读可写状态等行为都是相同的逻辑流程。Channel 和 Selector 对这套相同的逻辑流程进行了封装,Channel 类通过暴露 setWriteEventCallbacksetReadEventCallback 等接口,来实现不同文件描述符上的读写操作;Selector 类通过其 updateChannel 成员方法将 Channel 对象添加到 IO multiplexing 函数中管理。

EventLoop

EventLoop 成员变量如下。

class EventLoop
{
public:
	using Func = std::function<void()>;

private:
	using ChannelVector = std::vector<Channel*>;

	const std::thread::id tid_;     // EventLoop所在线程的唯一标识
	std::unique_ptr<Selector> selector_;

	std::atomic_bool looping_;
	std::atomic_bool running_;

	std::unique_ptr<TimerQueue> timerQueue_;

	// 下面这些成员变量与跨线程调用有关
	std::mutex mutex_;
	int wakeupFd_;
	std::unique_ptr<Channel> wakeupChannel_;
	std::vector<Func> pendingFunctions_;
	bool callingPendingFunctions_;
};

one loop per thread 对应的实现上,loop 即指 EventLoop。EventLoop 类自己不负责具体的事物处理,具体的事件循环委托给其 Selector 成员变量来实现,它起到桥梁的作用,把Channel 和 Selector 连接起来,Selector 管理并监听文件描述符是可读还是可写,Channel 负责读写事件发生后需要进行的操作(通过回调函数实现),EventLoop 将两者串联起来。看下 EventLoop 中事件循环的核心代码。

void EventLoop::loop()
{
	// using ChannelVector = std::vector<Channel*>;
	// 保存IO multiplexing检测到的有事件发生的文件描述符对应的Channel
	ChannelVector activeChannels;
	while (running_)
	{
		activeChannels.clear();

		// selector = new Epoll(...);
		selector_->select(activeChannels, Epoll::EPOLL_TIMEOUT);

		// 执行 events 上注册的回调函数
		for (auto channel : activeChannels)
		{
			channel->handleEvents();
		}
	}

	looping_ = false;
}

这里贴出 Epoll 类中 select 成员函数和 Channel 类中 handleEvents 成员函数的实现,方便与上述代码对照。

void Epoll::select(ChannelVector& activeChannels, int timeout)
{
	int returnedEventsNum = epoll_wait(epollFd_, events_.data(), static_cast<int>(events_.size()), timeout);
	LOG_DEBUG << "epoll_wait once...";
	if (returnedEventsNum < 0) {
		// FIXME: error
	}
	else if (returnedEventsNum == 0) {
		// no events happening, maybe timeout
	}
	else {
		// 取出发生的事件
		assert(static_cast<size_t>(returnedEventsNum) <= events_.size());
		for (int i = 0; i < returnedEventsNum; ++i) {
			Channel* channel = static_cast<Channel*>(events_[i].data.ptr);
		
			channel->setReturnedEvent(events_[i].events);
			activeChannels.emplace_back(channel);
		}

		/**
		 * 当前用户态中用于接收事件的数组太小,不能一次性从内核态拷贝到用户态,把数组大小扩大一倍
		*/
		if (static_cast<size_t>(returnedEventsNum) == events_.size()) {
			events_.resize(events_.size() * 2);
		}
	}
}

void Channel::handleEvents()
{
	if (returnedEvents_ & POLLOUT) {
		if (writeEventCallback_) {
			writeEventCallback_();
		}
	}

	if (returnedEvents_ & POLLNVAL) {
		// POLLNVAL - Invalid request: fd not open (only returned in revents; ignored in events).
	}

	if (returnedEvents_ & POLLERR) {
		if (errorEventCallback_) {
			errorEventCallback_();
		}
	}

	if (returnedEvents_ & (POLLIN | POLLPRI | POLLRDHUP)) {
		if (readEventCallback_) {
			readEventCallback_();
		}
	}

	if (returnedEvents_ & POLLHUP && !(returnedEvents_ & POLLIN)) {
		if (closeEventCallback_) {
			closeEventCallback_();
		}
	}
}

one loop per thread 中的 “per thread” 指每个线程中最多有一个事件循环,一个事件循环只属于一个线程,将创建了EventLoop实例对象的线程称为IO线程。muduo的实现中,EventLoop实例对象可以跨线程调用,但都会被转到创建 EventLoop 实例对象所属的线程中执行,至于为什么这么做,muduo书中的4.6节多线程与IO给出了很好的解释。下面看下EventLoop的跨线程调用是如何实现的。

EventLoop 对外提供了 runInLoop 接口,允许跨线程调用,若调用 loop.runInLoop 函数的线程是创建 loop 对象时所在的线程,则直接执行,否则调用 queueInLoop 函数。

bool isInLoopThread() const { return tid_ == std::this_thread::get_id(); }

void EventLoop::runInLoop(Func func)
{
	if (func)
	{
		if (isInLoopThread())
		{
			func();
		}
		else
		{
			queueInLoop(std::move(func));
		}
	}
}

void EventLoop::queueInLoop(Func func)
{
	{
		std::unique_lock<std::mutex> locker(mutex_);
		pendingFunctions_.emplace_back(std::move(func));
	}

	if (!isInLoopThread() || callingPendingFunctions_)
	{
		wakeup();
	}
}

queueInLoop 将待执行的函数添加到一个函数队列中(std::vector<Func>),然后根据条件来判断是否调用 wakeup 函数唤醒 loop 被创建时所在的线程,让待执行的函数在 loop 所在的线程执行。因为 loop 所在的线程会被阻塞在IO 复用函数上(例如,epoll_wait),而 loop.runInLoop(func) 会将 func 转移到 loop 所在的线程执行,且我们是希望 func 能够被立马执行,而不要被阻塞,因此当 loop 所在的线程被阻塞在 epoll_wait 上时,我们需要唤醒它,然后执行该函数。执行流程如下所示:

// 调用 eventfd 创建一个 fd
int createEventfd()
{
	int fd = eventfd(0, EFD_CLOEXEC | EFD_NONBLOCK | EFD_SEMAPHORE);
	if (fd < 0)
	{
		LOG_FATAL << "eventfd*() error";
	}
	return fd;
}

EventLoop::EventLoop() : looping_(false), running_(false),
                             tid_(std::this_thread::get_id()),
                             selector_(new Epoll(this)),
                             wakeupFd_(createEventfd()),
                             wakeupChannel_(new Channel(this, wakeupFd_)),
                             callingPendingFunctions_(false),
                             timerQueue_(new TimerQueue(this))
{
	LOG_DEBUG << "EventLoop created.";

	if (loopInCurrentThread)
	{
		// 一个线程中最多创建一个 EventLoop 对象
		LOG_FATAL << "A thread can create a maximum of one EventLoop object.";
	}
	else
	{
		loopInCurrentThread = this;
	}

	// EventLoop对象初始化时,创建一个 wakeupFd_ 和对应的 Channel,然后关注读事件
	wakeupChannel_->setReadEventCallback(std::bind(&EventLoop::wakeupReadCallback, this));
	wakeupChannel_->enableRead();
}

// wakeup 会往 wakeupFd_ 写入数据; 然后 epoll_wait 监测到 wakeupFd_ 可读,阻塞解除
void EventLoop::wakeup()
{
	uint64_t one = 1;
	ssize_t n = ::write(wakeupFd_, &one, sizeof(one));
	if (n != sizeof(one))
	{
		LOG_ERROR << "EventLoop::wakeup() should write 8 bytes, not " << n << " bytes";
	}
}

void EventLoop::loop()
{
	// ...

	ChannelVector activeChannels;
	while (running_)
	{
		activeChannels.clear();

		// 当没有事件发生,select被阻塞。wakeup() 函数将此调用唤醒
		selector_->select(activeChannels, Epoll::EPOLL_TIMEOUT);
	
		// ...

		// 执行 pendingFunctions_ 中保存的函数对象
		doPendingFunctions();
	}

	looping_ = false;
}

void EventLoop::doPendingFunctions()
{
	std::vector<Func> functions;
	callingPendingFunctions_ = true;

	{
		std::unique_lock<std::mutex> locker(mutex_);
		functions.swap(pendingFunctions_);
	}

	for (auto &func : functions)
	{
		func();
	}

	callingPendingFunctions_ = false;
}

EventLoop还提供了定时器的接口,在 《定时器》中介绍该部分。


至此,单线程下的 one loop per thread介绍完毕,使用 EventLoop 类就能够很方便的构建出一个简单的单线程TCP程序。多线程下的 one loop per thread 的实现在 《muduo源码分析之Thread、EventLoopThread和EventLoopThreadPool》中讲解。

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

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

群发邮件软件哪个好

选择一个好的群发邮件软件取决于您的具体需求&#xff0c;如预算、邮件量、自动化需求、分析深度以及是否需要集成其他营销工具等。以下是一些评价较高且功能强大的群发邮件软件&#xff0c;您可以根据自身情况选择&#xff1a; 易邮件群发大师&#xff1a;一款传统使用最广泛的…

【项目学习01_2024.05.05_Day05】

学习笔记 4.3 接口开发4.3.1 树型表查询4.3.2 开发Mapper4.3.3 开发Service4.3.4 测试Service 4.4 接口测试4.4.1 接口层代码完善4.4.2 测试接口 4.3 接口开发 4.3.1 树型表查询 4.3.2 开发Mapper 在对应的Mapper里定义一个方法 在同名的xml文件里具体定义相应的sql语句 4…

阿里实习生:面试阿里其实并没有那么难。

愉快的五一假期已经结束了, 又要投入到学习和工作当中了。 今天分享一位同学在阿里的Go后端实习面经详解, 希望对你有帮助。 Go里有哪些数据结构是并发安全的&#xff1f; 并发安全就是程序在并发的情况下执行的结果都是正确的&#xff1b; Go中数据类型分为两大类&#xff…

探秘Tailwind CSS:前端开发的加速器(Tailwind CSS让CSS编写更简洁)

文章目录 📖 介绍 📖🏡 演示环境 🏡📒 Tailwind CSS 📒📝 快速体验📝 深入学习⚓️ 相关链接 ⚓️📖 介绍 📖 在这个快速迭代的互联网时代,前端开发效率和设计质量的双重要求,使得开发者们不断寻求更高效的工具和方法。今天,我们要介绍的是一个能够极大…

【数据库原理及应用】期末复习汇总高校期末真题试卷03

试卷 一、选择题 1 数据库中存储的基本对象是_____。 A 数字 B 记录 C 元组 D 数据 2 下列不属于数据库管理系统主要功能的是_____。 A 数据定义 B 数据组织、存储和管理 C 数据模型转化 D 数据操纵 3 下列不属于数据模型要素的是______。 A 数据结构 B 数据字典 C 数据操作 D…

Python基础学习之装饰器

大家好&#xff0c;今天我想和大家分享一下Python中一个非常强大且优雅的特性——装饰器&#xff08;Decorators&#xff09;。装饰器在Python中是一种高级语法&#xff0c;它允许你在不修改函数或类的情况下&#xff0c;为其添加额外的功能。这不仅让代码更加整洁&#xff0c;…

Coze扣子开发指南:怎么使用功能强大的插件?

●插件是什么&#xff1f; 想象一下&#xff0c;你的机器人是一个玩具车&#xff0c;它本来只能跑直线。但是&#xff0c;如果你给它装上一些额外的小配件&#xff0c;比如翅膀&#xff0c;它就能飞&#xff1b;装上轮子&#xff0c;它就能在各种地形上跑。这些小配件&#xf…

关于IDEA中项目中各个方法、引用、注解等全部报错的情况

今天打开项目弹出很多提示框&#xff0c;也没注意&#xff0c;然后突然发现项目所有都在报错&#xff0c;不管是启动类还是方法类&#xff0c;各种注解、方法、引用等全红了&#xff0c;随便打开一个都是密密麻麻全红。 首先排查依赖和JDK等引用问题&#xff0c;包括我们的mave…

多线程使用说明

一、如何创建多线程 1、继承Thread类 如果调用run方法&#xff0c;相当于还是只有一条main线程&#xff0c;会把run的线程当成一条普通对象&#xff0c;如下&#xff0c;t会执行完再往下执行&#xff0c;这样t就不是一个线程类&#xff0c;而是一个普通的对象&#xff0c;所以必…

(四)机器学习在银行中的典型应用场景(模型) #CDA学习打卡

本文总结了机器学习在银行中的典型业务应用场景&#xff0c;包括客户管理、零售智能营销、公司智能营销、自然语言处理、运营管理以及图像识别。

通过自适应提示提升大语言模型的零样本推理能力

随着大模型&#xff08;LLMs&#xff09;的快速发展&#xff0c;它们在自然语言处理&#xff08;NLP&#xff09;任务上取得了前所未有的成就。特别是&#xff0c;LLMs展现出了强大的推理和规划能力&#xff0c;这得益于它们的少样本和零样本学习能力。然而&#xff0c;现有的方…

三分钟一条抖音爆款短视频,轻松日引500+创业粉,复制粘贴即可,简单好…

详情介绍 团队历经三个月终于给兄弟把这个抖音测试出来了过程就不说了全是泪 最近抖音拆解项目是比较火的&#xff0c;前段时间不行拉现在又是可以继续拆解拉我这边自己也实操的一个引流渠道 咱们为什么要通过抖音来引流创业粉啊 因为抖音和知乎的创业粉的质量还是比较高的 本次…

【SQL每日一练】统计复旦用户8月练题情况

文章目录 题目一、分析二、题解1.使用case...when..then2.使用if 题目 现在运营想要了解复旦大学的每个用户在8月份练习的总题目数和回答正确的题目数情况&#xff0c;请取出相应明细数据&#xff0c;对于在8月份没有练习过的用户&#xff0c;答题数结果返回0. 示例代码&am…

线程安全的概念及原因

1.观察线程不安全 public class ThreadDemo {static class Counter {public int count 0;void increase() {count;}}public static void main(String[] args) throws InterruptedException {final Counter counter new Counter();Thread t1 new Thread(() -> {for (int …

腾讯云服务器之ssh远程连接登录

一、创建密钥绑定实例 创建密钥会自动下载一个私钥&#xff0c;把这个私钥复制到c盘 二、设置私钥权限 1、删除所有用户权限 2、添加当前用户权限 查看当前用户名 echo %USERNAME%三、ssh远程连接到服务器 ssh ubuntu175.xxx.xxx.112 -i C:\Crack\cs2.pem四、修改root密码 s…

构建第一个ArkTS应用之@LocalStorage:页面级UI状态存储

LocalStorage是页面级的UI状态存储&#xff0c;通过Entry装饰器接收的参数可以在页面内共享同一个LocalStorage实例。LocalStorage也可以在UIAbility实例内&#xff0c;在页面间共享状态。 本文仅介绍LocalStorage使用场景和相关的装饰器&#xff1a;LocalStorageProp和LocalS…

修改JupyterNotebook文件存储位置

Jupyter Notebook 1、通过AnaConda安装Jupyter Notebok 2、在开始菜单里找到并打开Anaconda Prompt&#xff0c;输入如下命令&#xff0c;然后执行。 jupyter notebook --generate-config4、打开以下文件 找到 C:/Userzh/.../.jupyter 打开 jupyter_notebook_config.py 取消…

信息系统项目管理师——第20章高级项目管理

本章是将第三版的第20章、第21章、第18章、第25章、第2章的PRINCE2进行了合并&#xff0c;精简和新增了部分知识。选择、案例都会考。从2023年上半年考情来看 选择题&#xff0c;考3-4分&#xff0c;基本是课本原话&#xff0c;但是知识点比较分散&#xff0c;需要多刷题&#…

HTML5实现酷炫个人产品推广、工具推广、信息推广、个人主页、个人介绍、酷炫官网、门户网站模板源码

文章目录 1.设计来源1.1 主界面1.2 我的产品界面1.3 关于我们界面1.4 照片墙界面1.5 发展历程界面1.6 优秀人才界面1.7 热门产品界面1.8 联系我们界面 2.灵活调整模块3.效果和源码3.1 动态效果3.2 源代码 源码下载 作者&#xff1a;xcLeigh 文章地址&#xff1a;https://blog.c…

python中怎么清屏

一、“Windows命令行窗口”下清屏&#xff0c;可用下面两种方法&#xff1a; 第一种方法&#xff0c;在命令行窗口输入&#xff1a; import os ios.system("cls") 第二种方法&#xff0c;在命令行窗口输入&#xff1a; import subprocess isubprocess.call("cl…
最新文章