线程池:
一种线程使用模式。线程过多会带来调度开销,进而影响缓存局部性和整体性能。而线程池维护着多个线程,等待着监督管理者分配可并发执行的任务。这避免了在处理短时间任务时创建与销毁线程的代价。线程池不仅能够保证内核的充分利用,还能防止过分调度。可用线程数量应该取决于可用的并发处理器、处理器内核、内存、网络sockets等的数量。
线程池的应用场景:
线程池示例:
LockGuard.hpp
#pragma once
#include
#include
class Mutex
{
public:
Mutex(pthread_mutex_t *lock_p = nullptr) : lock_p_(lock_p)
{}
void lock()
{
if (lock_p_)
pthread_mutex_lock(lock_p_);
}
void unlock()
{
if (lock_p_)
pthread_mutex_unlock(lock_p_);
}
~Mutex()
{}
private:
pthread_mutex_t *lock_p_;
};
class LockGuard
{
public:
LockGuard(pthread_mutex_t *mutex) : mutex_(mutex)
{
mutex_.lock(); // 在构造函数中进行加锁
}
~LockGuard()
{
mutex_.unlock(); // 在析构函数中进行解锁
}
private:
Mutex mutex_;
};
对锁的封装
class LockGuard
{
public:
LockGuard(pthread_mutex_t *mutex) : mutex_(mutex)
{
mutex_.lock(); // 在构造函数中进行加锁
}
~LockGuard()
{
mutex_.unlock(); // 在析构函数中进行解锁
}
private:
Mutex mutex_;
};
定义了一个名为 LockGuard
的类,它实现了 RAII(Resource Acquisition Is Initialization)技术,用于自动加锁和解锁互斥量。
具体来说,LockGuard
类的构造函数接受一个指向 pthread_mutex_t
类型的指针作为参数,并在函数内部将该指针封装成一个 Mutex
对象并进行加锁操作。在 LockGuard
对象创建时,该互斥量将被自动加锁,确保线程安全。
LockGuard
类的析构函数在对象销毁时自动调用,并在函数内部对互斥量进行解锁操作。在 LockGuard
对象销毁时,该互斥量将被自动解锁,释放锁资源。
通过使用 LockGuard
类,可以避免手动加锁和解锁互斥量,从而减少代码出错的可能性,并确保线程安全。同时,使用 RAII
机制可以提高代码的可读性和可维护性,使得程序更加健壮和可靠。
RAII(Resource Acquisition Is Initialization)技术是一种 C++ 语言的编程技术,它的基本思想是:在对象的构造函数中获取资源,在对象的析构函数中释放资源,从而实现资源的自动管理。
RAII 技术通常用于管理资源对象,例如内存、文件句柄、互斥锁、临界区等等,这些资源需要在使用完成后手动释放,否则会导致资源泄露或者资源使用冲突等问题。
使用 RAII 技术,可以避免手动管理资源带来的繁琐和容易出错的问题。RAII 技术的关键是利用对象的构造函数和析构函数来自动管理资源,当对象超出作用域时,其析构函数自动被调用,从而释放资源。
在 C++ 中,STL 中的智能指针和标准库中的各种容器,例如 vector、map、set等都是使用 RAII 技术实现的,它们可以自动管理内存和容器元素的生命周期,从而避免了手动管理资源的繁琐和容易出错的问题。
定义了一个名为 Mutex
的类,它封装了 pthread_mutex_t
互斥量,并提供了 lock()
和 unlock()
成员函数,用于加锁和解锁互斥量
#pragma once
#include
#include
class Mutex
{
public:
Mutex(pthread_mutex_t *lock_p = nullptr) : lock_p_(lock_p)
{}
void lock()
{
if (lock_p_)
pthread_mutex_lock(lock_p_);
}
void unlock()
{
if (lock_p_)
pthread_mutex_unlock(lock_p_);
}
~Mutex()
{}
private:
pthread_mutex_t *lock_p_;
};
Mutex
类的构造函数接受一个指向 pthread_mutex_t
类型的指针作为参数,并在函数内部将该指针保存到成员变量 lock_p_
中。如果构造函数没有接收到任何参数,则将 lock_p_
初始化为 nullptr
Mutex
类的 lock()
成员函数用于加锁互斥量。它首先检查 lock_p_
是否为 nullptr
,如果不是,则调用 pthread_mutex_lock()
函数对互斥量进行加锁操作。
Mutex
类的 unlock()
成员函数用于解锁互斥量。它首先检查 lock_p_
是否为 nullptr
,如果不是,则调用 pthread_mutex_unlock()
函数对互斥量进行解锁操作。
通过使用 Mutex 类,可以封装 pthread_mutex_t 互斥量,并提供了方便的 lock() 和 unlock() 成员函数,用于加锁和解锁互斥量。这使得代码更加简洁和易于管理,并确保线程安全。同时,Mutex 类的实现也符合 RAII 技术,可以自动释放锁资源,从而减少代码出错的可能性。
Thread.hpp
#pragma once
#include
#include
#include
#include
#include
#include
namespace ThreadNs
{
typedef std::function<void *(void *)> func_t;
const int num = 1024;
class Thread
{
private:
static void *start_routine(void *args) // 类内成员,有缺省参数!
{
Thread *_this = static_cast<Thread *>(args);
return _this->callback();
}
public:
Thread()
{
char namebuffer[num];
snprintf(namebuffer, sizeof namebuffer, "thread-%d", threadnum++);
name_ = namebuffer;
}
void start(func_t func, void *args = nullptr)
{
func_ = func;
args_ = args;
int n = pthread_create(&tid_, nullptr, start_routine, this);
assert(n == 0);
(void)n;
}
void join()
{
int n = pthread_join(tid_, nullptr);
assert(n == 0);
(void)n;
}
std::string threadname()
{
return name_;
}
~Thread()
{
// do nothing
}
void *callback() { return func_(args_); }
private:
std::string name_; // 线程名字
func_t func_; // 任务处理函数
void *args_; // 任务处理函数的参数
pthread_t tid_; // 线程的 ID
static int threadnum; // 计算线程个数,用于格式化线程名字为其增加编号
};
int Thread::threadnum = 1;
}
typedef std::function<void *(void *)> func_t;
定义了一个名为 func_t
的类型别名,它是一个函数对象类型,用于封装一个可调用对象,接受一个指针参数并返回一个指针值
static void *start_routine(void *args) // 类内成员,有缺省参数!
{
Thread *_this = static_cast<Thread *>(args);
return _this->callback();
}
在类内创建线程,想让线程执行对应的方法,需要将方法设置成为static
作用是创建并初始化线程
Thread()
{
char namebuffer[num];
snprintf(namebuffer, sizeof namebuffer, "thread-%d", threadnum++);
name_ = namebuffer;
}
构造函数Thread()
,它的作用是创建并初始化线程
定义了一个字符数组 namebuffer
,用来存储线程的名称。然后,使用 snprintf
函数将线程的名称格式化成字符串,并将该字符串存储在 namebuffer
中。其中,threadnum
是一个静态变量,用来记录已经创建的线程数量,每次创建线程时都会将 threadnum 加 1
,从而保证每个线程都有一个唯一的名称
启动一个新的线程并开始执行指定的任务处理函数
void start(func_t func, void *args = nullptr)
{
func_ = func;
args_ = args;
// 调用 pthread_create() 函数创建一个新的线程,并将线程 ID 保存到成员变量 tid_ 中
int n = pthread_create(&tid_, nullptr, start_routine, this); // TODO
assert(n == 0);
(void)n;
}
定义了 Thread
类的成员函数 start()
,它的作用是启动一个新的线程并开始执行指定的任务处理函数。一个任务处理函数指针 func
和一个可选的参数 args
,表示任务处理函数的参数。
等待线程执行结束并回收线程资源。
void join()
{
int n = pthread_join(tid_, nullptr);
assert(n == 0);
(void)n;
}
Task.hpp
#pragma once
#include
#include
#include
#include
class Task
{
using func_t = std::function<int(int, int, char)>;
public:
Task()
{
}
Task(int x, int y, char op, func_t func)
: _x(x), _y(y), _op(op), _callback(func)
{
}
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;
};
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 '*':
result = 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;
}
using func_t = std::function<int(int, int, char)>;
一个函数对象类型,用于封装一个可调用对象,接受两个 int
类型参数和一个 char
类型参数,并返回一个 int
类型值。
具体来说,func_t
是一个 std::function
模板类的实例化类型,它接受一个函数类型作为模板参数,其中该函数类型接受两个 int 类型参数和一个 char 类型参数,分别表示运算符两侧的操作数和运算符本身,并返回一个 int 类型值,表示运算结果。
因此,func_t 类型可以用于封装一个可调用对象,该对象接受两个 int 类型参数和一个 char 类型参数,并返回一个 int 类型值,类似于 C 语言中的函数指针类型。
在本例中,func_t
类型被用作 Task
类的成员变量 _callback
的类型,它用于封装一个计算加减乘除的回调函数,该函数接受两个 int 类型参数和一个 char 类型参数,并返回一个 int 类型值。在 Task 类的成员函数中,可以通过调用成员变量 _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;
}
定义了一个函数调用运算符的重载函数 operator()
调用成员变量 _callback
所指向的回调函数,传递成员变量 _x、_y、_op
作为参数,并将返回值存储在 result
变量中。使用 snprintf
函数将计算结果格式化为一个字符串,并将其存储在 buffer
数组中。最后,它将 buffer
数组转换为一个 std::string
对象,并返回该对象。
作用是将任务的信息格式化成字符串并返回。
std::string toTaskString()
{
char buffer[1024];
snprintf(buffer, sizeof buffer, "%d %c %d = ?", _x, _op, _y);
return buffer;
}
使用了 C 标准库中的 sprintf
函数将 _x、_op 、_y
这三个成员变量格式化成字符串,并将结果存储在字符数组 buffer
中。其中,_x 、_y
分别表示任务要操作的两个数,_op
表示操作符(例如加号、减号等)。然后,通过 std::string
类型的构造函数将字符数组 buffer
转换成字符串,并将该字符串作为函数的返回值。
ThreadPool.hpp
#pragma once
#include "Thread.hpp"
#include "LockGuard.hpp"
#include
#include
#include
#include
#include
using namespace ThreadNs;
const int gnum = 3;
template <class T>
class ThreadPool;
template <class T>
class ThreadData
{
public:
ThreadPool<T> *threadpool;
std::string name;
public:
ThreadData(ThreadPool<T> *tp, const std::string &n) : threadpool(tp), name(n)
{}
};
template <class T>
class ThreadPool
{
private:
static void *handlerTask(void *args)
{
ThreadData<T> *td = (ThreadData<T> *)args;
while (true)
{
T t;
{
LockGuard lockguard(td->threadpool->mutex());
while (td->threadpool->isQueueEmpty())
{
td->threadpool->threadWait();
}
t = td->threadpool->pop();
}
std::cout << td->name << " 获取了一个任务: " << t.toTaskString() << " 并处理完成,结果是:"
<< t() << std::endl;
}
delete td; // 删除new出的对象
return nullptr;
}
ThreadPool(const int &num = gnum) : _num(num)
{
pthread_mutex_init(&_mutex, nullptr);
pthread_cond_init(&_cond, nullptr);
for (int i = 0; i < _num; i++)
{
_threads.push_back(new Thread());
}
}
void operator=(const ThreadPool &) = delete;
ThreadPool(const ThreadPool &) = delete;
public:
void lockQueue() { pthread_mutex_lock(&_mutex); }
void unlockQueue() { pthread_mutex_unlock(&_mutex); }
bool isQueueEmpty() { return _task_queue.empty(); }
void threadWait() { pthread_cond_wait(&_cond, &_mutex); }
T pop()
{
T t = _task_queue.front();
_task_queue.pop();
return t;
}
pthread_mutex_t *mutex()
{
return &_mutex;
}
public:
void run()
{
for (const auto &t : _threads)
{
ThreadData<T> *td = new ThreadData<T>(this, t->threadname());
t->start(handlerTask, td);
std::cout << t->threadname() << " start ..." << std::endl;
}
}
void push(const T &in)
{
LockGuard lockguard(&_mutex);
_task_queue.push(in);
pthread_cond_signal(&_cond);
}
// 析构函数
~ThreadPool()
{
pthread_mutex_destroy(&_mutex);
pthread_cond_destroy(&_cond);
for (const auto &t : _threads)
delete t;
}
static ThreadPool<T> *getInstance()
{
if (nullptr == tp)
{
_singlock.lock();
if (nullptr == tp)
{
tp = new ThreadPool<T>();
}
_singlock.unlock();
}
return tp;
}
private:
int _num;
std::vector<Thread *> _threads; // 存放创建的线程实例的指针
std::queue<T> _task_queue; // 存储待执行的任务。它是一个先进先出(FIFO)的队列
pthread_mutex_t _mutex; // pthread 库中的互斥锁类型,用于保护任务队列的访问
pthread_cond_t _cond; // pthread 库中的条件变量类型,用于线程之间的同步和通信。
// 在任务队列为空时,线程需要等待条件变量的信号,以便在有新的任务加入时立即处理。
// 而当任务加入队列时,需要发送条件变量的信号,以通知等待的线程有新的任务可以处理了。
// tp 是一个指向 ThreadPool 类的指针,它是静态变量,只有一个实例,用于在整个程序中共享线程池的实例。
// 由于线程池是一个全局的资源,需要确保所有的线程都共享同一个实例,避免资源浪费和线程安全问题。
static ThreadPool<T> *tp; // 指向 ThreadPool 类的指针 tp
// 由于线程池是一个单例模式,需要确保只有一个实例被创建,避免资源浪费和线程安全问题。因此,当多个线程同时访问时,
// 需要使用互斥锁对其进行保护,避免多个线程同时创建线程池实例
static std::mutex _singlock; // std::mutex 类型的互斥锁 _singlock。语言层面
};
// tp 是一个指向 ThreadPool 类对象的指针,初始值为 nullptr。
// 它被用于实现 ThreadPool 类的单例模式,确保每个程序只有一个 ThreadPool 类对象。
template <class T>
ThreadPool<T> *ThreadPool<T>::tp = nullptr;
// _singlock 是一个互斥量,用于保护对静态成员变量 tp 的访问。
// 它被用于实现线程安全的单例模式,避免多个线程同时创建 ThreadPool 类对象,导致资源浪费和线程安全问题。
template <class T>
std::mutex ThreadPool<T>::_singlock;
template <class T>
class ThreadData
{
public:
ThreadPool<T> *threadpool;
std::string name;
public:
ThreadData(ThreadPool<T> *tp, const std::string &n) : threadpool(tp), name(n)
{}
};
定义了一个名为ThreadData
的模板类,用于封装线程池和线程之间的关系,保存了线程池的指针和线程的名称。
一个指向线程池的指针 threadpool
,一个表示线程名称的字符串 name
。它们分别用于保存线程池的指针和线程的名称。
接受两个参数,分别是线程池的指针 tp
和线程的名称 n
。构造函数将这两个参数保存到 ThreadData
对象的成员变量中,以便在后续的线程处理函数中使用。
通过创建一个
ThreadData
类型的对象,并将线程池的指针和线程的名称传递给其构造函数,可以封装线程池和线程之间的关系,并确保线程池的正确运行。在启动线程时,会将该对象的指针传递给线程,并在处理任务时使用其中的线程池指针。这样可以简化线程池的实现,提高线程池的可维护性和可扩展性。
作为线程的入口函数,循环从任务队列中获取任务并执行。
static void *handlerTask(void *args)
{
ThreadData<T> *td = (ThreadData<T> *)args;
while (true)
{
//定义一个类型为 T 的变量 t,用来存储从任务队列中获取到的任务。
T t;
{
LockGuard lockguard(td->threadpool->mutex());
while (td->threadpool->isQueueEmpty())
{
td->threadpool->threadWait();
}
t = td->threadpool->pop();
}
// 格式化输出信息
std::cout << td->name << " 获取了一个任务: " << t.toTaskString() << " 并处理完成,结果是:"
<< t() << std::endl;
}
delete td; // 删除new出的对象
return nullptr;
}
LockGuard lockguard(td->threadpool->mutex());
这段代码使用了 RAII(Resource Acquisition Is Initialization)
技术,通过定义一个局部变量 LockGuard
对象 lockguard
,实现了自动获取和释放互斥锁的操作。LockGuard
是一个 RAII
类模板,它的构造函数会在对象创建时获取互斥锁,析构函数会在对象销毁时释放互斥锁。这样,在函数执行过程中,只需要将需要保护的代码块放在 LockGuard
对象的作用域内,就可以保证在退出作用域时自动释放互斥锁,避免了手动释放锁的繁琐操作和可能的遗漏。
在这段代码中,LockGuard
对象 lockguard
的构造函数被调用时,传入了一个指向互斥锁的指针 td->threadpool->mutex()
,表示获取该互斥锁。当 lockguard
对象的作用域结束时,析构函数自动释放该互斥锁,这样就保证了在访问任务队列时的线程安全。
while (td->threadpool->isQueueEmpty())
从任务队列中获取任务并执行,它的核心是一个循环,不断尝试从任务队列中获取任务,直到获取到任务为止。
td->threadpool->threadWait();
将当前线程挂起,等待有新的任务加入队列或者线程被停止。如果队列不为空,则执行下一步操作。
t = td->threadpool->pop();
从任务队列中取出一个任务,并将其赋值给变量 t。这个 pop 方法的本质是将任务从公共队列中拿到,当前线程自己独立的栈中,以避免多个线程同时访问同一个任务对象的线程安全问题。
ThreadPool(const int &num = gnum) : _num(num)
{
pthread_mutex_init(&_mutex, nullptr);
pthread_cond_init(&_cond, nullptr);
for (int i = 0; i < _num; i++)
{
_threads.push_back(new Thread());
}
}
ThreadPool
构造函数,它的作用是创建并初始化线程池。接受一个 int
类型的参数num
,用来指定线程池中线程的数量。如果没有传入 num,就使用全局变量 gnum 的默认值。然后,构造函数初始化了线程池中的互斥量和条件变量,以及线程池的大小。
使用 pthread_mutex_init
函数初始化互斥量 _mutex
,使用 pthread_cond_init
函数初始化条件变量 _cond
,这两个函数都是 POSIX 线程库中的函数,用来创建和初始化互斥量和条件变量。
使用一个 for
循环创建线程池中的线程。每次循环创建一个 Thread
类的实例,并将其指针添加到线程池的 _threads
向量中。这个 _threads
向量是用来存储线程池中所有线程的指针的,它的大小就是线程池的大小,即 num
。
void operator=(const ThreadPool &) = delete;
ThreadPool(const ThreadPool &) = delete;
定义了 ThreadPool
类的拷贝赋值运算符和拷贝构造函数,并将它们声明为 delete
,表示禁止进行拷贝构造和拷贝赋值操作。
因为线程池是一个资源管理类,它包含了多个线程、任务队列、互斥锁、条件变量等资源,这些资源的管理和释放都需要仔细考虑。如果允许进行拷贝构造和拷贝赋值操作,就会导致多个对象共享同一份资源,可能会引起资源泄露、线程安全问题等等。
为了避免这种情况,通常会将拷贝构造函数和拷贝赋值运算符声明为 delete,表示禁止进行拷贝操作。这样,就可以确保每个线程池都拥有自己独立的资源,避免了资源共享带来的问题。需要注意的是,这段代码中使用了 C++11 中的新特性,即使用 “= delete” 显式声明某个函数为删除函数。这种方式可以在编译期间检查是否存在拷贝构造和拷贝赋值操作,避免了在运行时出现错误。
void lockQueue() { pthread_mutex_lock(&_mutex); }
使用 pthread_mutex_lock
函数对任务队列的互斥锁进行加锁操作,以保证在多个线程同时访问时不会出现冲突。
void unlockQueue() { pthread_mutex_unlock(&_mutex); }
使用 pthread_mutex_unlock
函数对任务队列的互斥锁进行解锁操作,以释放资源并让其它线程获得访问权限。
bool isQueueEmpty() { return _task_queue.empty(); }
用于判断任务队列是否为空,它返回一个 bool 类型的值,如果任务队列为空则返回 true,否则返回 false。
void threadWait() { pthread_cond_wait(&_cond, &_mutex); }
使用 pthread_cond_wait
函数将线程放入等待状态,并等待条件变量的信号。该函数将在 _cond
条件变量上等待,并同时释放 _mutex
互斥锁,以便其它线程可以获得访问权限。当条件变量的信号发生时,该函数将重新获得 _mutex
互斥锁,并继续执行后续的任务处理操作。
将队列中的任务弹出,并返回弹出值
T pop()
{
T t = _task_queue.front();
_task_queue.pop();
return t;
}
返回一个指向线程池的互斥锁的指针。
pthread_mutex_t *mutex()
{
return &_mutex;
}
返回互斥锁的指针可以使得其它函数可以方便地对互斥锁进行加锁和解锁操作,以保证线程安全。同时,通过返回指针的方式,也避免了多余的复制操作和内存开销。
启动线程池中的所有线程,并开始处理任务。
void run()
{
for (const auto &t : _threads)
{
ThreadData<T> *td = new ThreadData<T>(this, t->threadname());
t->start(handlerTask, td);
std::cout << t->threadname() << " start ..." << std::endl;
}
}
run()
函数遍历线程池中的所有线程,并为每个线程创建一个 ThreadData
类型的对象 td。ThreadData
类型是一个模板类,用于封装线程池和线程的关系,保存了线程池的指针和线程的名称
定义了 ThreadPool 类的成员函数 push(),它的作用是向线程池中添加一个任务。
void push(const T &in)
{
LockGuard lockguard(&_mutex); // 使用 LockGuard 对象对互斥量进行加锁 RALL设计
_task_queue.push(in); // 进队列push
// 使用 pthread_cond_signal() 函数发送一个条件信号,以通知等待在条件变量 _cond 上的线程有新的任务可以处理。
pthread_cond_signal(&_cond);
}
作用是获取 ThreadPool 类的单例对象。
static ThreadPool<T> *getInstance()
{
if (nullptr == tp)
{
_singlock.lock();
if (nullptr == tp)
{
tp = new ThreadPool<T>();
}
_singlock.unlock();
}
return tp;
}
getInstance()
函数首先检查静态成员变量 tp
是否为空指针。如果 tp
不为空,直接返回 tp
指向的对象。否则,它使用单例模式的方式创建一个新的 ThreadPool
对象,并将其赋值给 tp
。在创建对象时,使用了双重检查锁定的方式来确保线程安全。最后,getInstance()
函数返回 tp 指向的对象。
调用 getInstance() 函数,可以获取 ThreadPool 类的单例对象。这使得线程池可以全局共享,并确保线程池的唯一性。通过使用单例模式,可以简化线程池的实现,提高代码的可维护性和可扩展性。
main.cc
#include "ThreadPool.hpp"
#include "Task.hpp"
#include
#include
int main()
{
// 调用了 ThreadPool 类的静态成员函数 getInstance() 和成员函数 run(),
// 用于获取 ThreadPool 类的单例对象并运行线程池
// getInstance() 函数返回一个指向 ThreadPool 类对象的指针,
// 该对象是全局唯一的,并使用了双重检查锁定的方式确保线程安全。然后,调用该对象的成员函数 run(),用于启动线程池的运行
ThreadPool<Task>::getInstance()->run();
int x, y;
char op;
while (1)
{
std::cout << "请输入数据1# ";
std::cin >> x;
std::cout << "请输入数据2# ";
std::cin >> y;
std::cout << "请输入你要进行的运算#";
std::cin >> op;
Task t(x, y, op, mymath);
ThreadPool<Task>::getInstance()->push(t);
sleep(1);
}
}
如有错误或者不清楚的地方欢迎私信或者评论指出