ARM64架构下ZTS线程池的实时性保障和达梦DM8事务快照的ZTS内存泄露和WebRTC媒体流的零拷贝内存桥接
·
---
一、ARM64 ZTS线程池实时性保障:Rust + 协程-线程混合调度器
大白话讲清楚问题
PHP 开 ZTS 之后,每个 worker 线程跑自己的 PHP 解释器,里面又跑 Swoole 协程。原来调度有三个坑:
1. 抢占不及时:协程是 N:1 协作式,一个慢协程拖住一整个线程,实时任务(比如音视频包处理)等不及。
2. 线程池僵化:worker 线程数一开始就固定,ARM64 鲲鹏 64 核机器上经常一半核闲着另一半忙死。
3. C 写的调度器用裸指针:协程 yield/resume 时栈对象生命周期管理全靠程序员,refcount 错一个就 use-after-free。
怎么救:Rust 写一层"安全调度抽象",对外暴露 C ABI 给 PHP/Swoole 调。Rust 的所有权 + Send/Sync
自动保证协程切换时栈对象不会越界访问;底层用 work-stealing 队列做线程池,加一个实时优先队列专门跑 deadline 任务,保证
μs级响应。
完整代码
// rt_scheduler/Cargo.toml
// [dependencies]
// crossbeam = "0.8"
// parking_lot = "0.12"
// libc = "0.2"
// once_cell = "1"
// rt_scheduler/src/lib.rs ——核心调度器
use crossbeam::deque::{Injector, Stealer, Worker};
use parking_lot::Mutex;
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};
use std::collections::BinaryHeap;
use std::cmp::Ordering;
use once_cell::sync::Lazy;
/// 协程任务句柄。栈/上下文用 Box 持有,Rust 帮你管生命周期
pub struct Coro {
pub cid: u64,
pub deadline: Option<Instant>, // 实时任务有deadline,普通任务None
pub priority: i8, // -20~19 类似nice
pub entry: Box<dyn FnOnce() + Send + 'static>,
}
// 实时队列按 deadline 排序,越早的越先跑
struct RtItem(Coro);
impl PartialEq for RtItem { fn eq(&self, o:&Self)->bool { self.0.deadline==o.0.deadline } }
impl Eq for RtItem {}
impl Ord for RtItem {
fn cmp(&self, o:&Self)->Ordering {
// BinaryHeap默认大顶堆,反转一下变小顶堆
o.0.deadline.cmp(&self.0.deadline)
}
}
impl PartialOrd for RtItem { fn partial_cmp(&self,o:&Self)->Option<Ordering>{Some(self.cmp(o))} }
/// 全局调度器:work-stealing 普通队列 + 实时小顶堆
pub struct Scheduler {
injector: Arc<Injector<Coro>>, // 全局兜底队列
stealers: Vec<Stealer<Coro>>, // 每个线程一个本地队列的偷取端
rt_queue: Arc<Mutex<BinaryHeap<RtItem>>>,// 实时队列(全局)
n_workers: usize,
}
static SCHED: Lazy<Scheduler> = Lazy::new(|| Scheduler::new(num_cpus_safe()));
fn num_cpus_safe() -> usize {
let n = unsafe { libc::sysconf(libc::_SC_NPROCESSORS_ONLN) };
if n <= 0 { 8 } else { n as usize }
}
impl Scheduler {
fn new(n: usize) -> Self {
let injector = Arc::new(Injector::new());
let rt_queue = Arc::new(Mutex::new(BinaryHeap::new()));
let mut workers = Vec::with_capacity(n);
let mut stealers = Vec::with_capacity(n);
for _ in 0..n {
let w = Worker::<Coro>::new_fifo();
stealers.push(w.stealer());
workers.push(w);
}
// 启动 worker 线程
for (idx, w) in workers.into_iter().enumerate() {
let inj = injector.clone();
let rt = rt_queue.clone();
let st = stealers.clone();
thread::Builder::new()
.name(format!("php-rt-{}", idx))
.spawn(move || worker_loop(idx, w, inj, rt, st))
.unwrap();
// ARM64 NUMA亲和:把线程钉到对应核
bind_cpu(idx);
}
Self { injector, stealers, rt_queue, n_workers: n }
}
}
/// 把线程绑到指定 CPU。鲲鹏 920 上一组核共享 L3,按组绑能减 cache miss
fn bind_cpu(cpu: usize) {
unsafe {
let mut set: libc::cpu_set_t = std::mem::zeroed();
libc::CPU_ZERO(&mut set);
libc::CPU_SET(cpu, &mut set);
libc::sched_setaffinity(0, std::mem::size_of_val(&set), &set);
}
}
/// 单个 worker 主循环
fn worker_loop(
idx: usize,
local: Worker<Coro>,
injector: Arc<Injector<Coro>>,
rt: Arc<Mutex<BinaryHeap<RtItem>>>,
stealers: Vec<Stealer<Coro>>,
) {
loop {
// 1. 实时队列优先:deadline 在 100us 内的立刻抢占
let now = Instant::now();
let urgent = {
let mut g = rt.lock();
match g.peek() {
Some(top) if top.0.deadline.map_or(false,
|d| d <= now + Duration::from_micros(100)) => g.pop(),
_ => None,
}
};
if let Some(item) = urgent {
run_coro(item.0);
continue;
}
// 2. 本地队列
if let Some(c) = local.pop() { run_coro(c); continue; }
// 3. 全局兜底
if let crossbeam::deque::Steal::Success(c) = injector.steal() {
run_coro(c); continue;
}
// 4. 偷别人
let mut stolen = None;
for (i, s) in stealers.iter().enumerate() {
if i == idx { continue; }
if let crossbeam::deque::Steal::Success(c) = s.steal() {
stolen = Some(c); break;
}
}
if let Some(c) = stolen { run_coro(c); continue; }
// 5. 啥也没有,短睡。注意不能太长否则实时任务延迟
thread::sleep(Duration::from_micros(50));
}
}
fn run_coro(c: Coro) {
// 这里就是真正调用 Swoole 协程入口的地方。
// Rust 的 FnOnce 自动消费所有权,闭包内的栈对象 Drop 时安全释放
(c.entry)();
}
/* ========== C ABI:暴露给 PHP/Swoole 调 ========== */
#[no_mangle]
pub extern "C" fn rs_sched_submit_normal(cid: u64, fn_ptr: extern "C" fn(*mut u8), arg: *mut u8) {
let arg_addr = arg as usize; // 跨线程传裸指针的标准做法:转 usize
let coro = Coro {
cid, deadline: None, priority: 0,
entry: Box::new(move || fn_ptr(arg_addr as *mut u8)),
};
SCHED.injector.push(coro);
}
#[no_mangle]
pub extern "C" fn rs_sched_submit_rt(cid: u64, deadline_us: u64,
fn_ptr: extern "C" fn(*mut u8), arg: *mut u8) {
let arg_addr = arg as usize;
let coro = Coro {
cid, priority: -20,
deadline: Some(Instant::now() + Duration::from_micros(deadline_us)),
entry: Box::new(move || fn_ptr(arg_addr as *mut u8)),
};
SCHED.rt_queue.lock().push(RtItem(coro));
}
#[no_mangle]
pub extern "C" fn rs_sched_workers() -> usize { SCHED.n_workers }
PHP 扩展侧接入(C 调 Rust)
/* php_rt_sched.c ——PHP扩展侧调用Rust */
#include "php.h"
extern void rs_sched_submit_normal(uint64_t cid, void(*fn)(void*), void *arg);
extern void rs_sched_submit_rt(uint64_t cid, uint64_t deadline_us, void(*fn)(void*), void *arg);
typedef struct { zend_fcall_info fci; zend_fcall_info_cache fcc; } php_task_t;
static void task_trampoline(void *arg) {
php_task_t *t = (php_task_t*)arg;
zval rv;
t->fci.retval = &rv;
zend_call_function(&t->fci, &t->fcc); /* 回到PHP用户态执行 */
zval_ptr_dtor(&rv);
efree(t);
}
PHP_FUNCTION(rt_go) {
zend_fcall_info fci; zend_fcall_info_cache fcc;
zend_long deadline_us = 0;
ZEND_PARSE_PARAMETERS_START(1, 2)
Z_PARAM_FUNC(fci, fcc)
Z_PARAM_OPTIONAL
Z_PARAM_LONG(deadline_us)
ZEND_PARSE_PARAMETERS_END();
php_task_t *t = emalloc(sizeof(*t));
t->fci = fci; t->fcc = fcc;
static _Atomic(uint64_t) cid_seq = 0;
uint64_t cid = atomic_fetch_add(&cid_seq, 1);
if (deadline_us > 0)
rs_sched_submit_rt(cid, deadline_us, task_trampoline, t);
else
rs_sched_submit_normal(cid, task_trampoline, t);
}
PHP 用法:
rt_go(function() { handle_audio_packet(); }, 200); // 200μs内必跑完
rt_go(function() { log_request(); }); // 普通后台
---
二、达梦DM8事务快照ZTS内存泄漏:TSRM ×MVCC版本链隔离
大白话讲清楚问题
达梦 DM8 用 MVCC:每个事务开始时拿一个快照号 SCN,读到的行如果被改过,要顺着"版本链"往回找属于自己 SCN 的那一版。
PHP ZTS 多线程跑时遇到的坑:
1. 快照泄漏:线程 A 开了事务读 100 万行,每行的旧版本 zval 都被持有;事务忘了 commit/rollback,TSRM 销毁线程时没人通知
DM8 释放快照,版本链一直挂着,内存爆。
2. 跨协程串话:协程 A 开事务,yield 走了,协程 B 在同一线程上跑别的查询——看到的快照号还是 A 的,读到错版本。
3. zval 指向版本链:版本链节点被 DM8 内部回收时,PHP 这边 zval 还指着,野指针。
怎么救:把 SCN + 版本链锚点放到协程级 TSRM 储物柜里,每个协程一份;协程销毁钩子里强制走 DM8 的释放接口;zval
改成"弱引用 + 引用代号",版本链回收前先把所有指向它的 zval 标记失效。
完整代码
/* dm_mvcc_iso.c ——DM8 MVCC 与 TSRM 协同 */
#include <stdatomic.h>
#include <pthread.h>
#include "dpi.h" /* DM8 DPI 接口 */
/* ---------- 协程级事务状态 ---------- */
typedef struct dm_ver_node {
uint64_t scn;
uint32_t row_id;
void *row_data;
_Atomic(uint32_t) ref; /* 还有几个zval指着我 */
struct dm_ver_node *next; /* 版本链 */
} dm_ver_node_t;
typedef struct {
uint64_t cid;
uint64_t snap_scn; /* 本协程快照号 */
dhcon conn; /* DM8 连接句柄 */
dhstmt stmt;
dm_ver_node_t *anchors[1024]; /* 持有的版本节点(锚点) */
uint32_t n_anchors;
int txn_active;
} dm_coro_state_t;
/* TSRM 资源ID */
extern int g_dm_state_rid;
/* 在协程储物柜里取/建状态 */
dm_coro_state_t* dm_state_get_or_create(uint64_t cid) {
extern void* lftsrm_resource_get(uint32_t);
extern uint32_t lftsrm_resource_new(size_t);
dm_coro_state_t *s = lftsrm_resource_get(g_dm_state_rid);
if (!s) {
uint32_t rid = lftsrm_resource_new(sizeof(*s));
s = lftsrm_resource_get(rid);
s->cid = cid;
}
return s;
}
/* ---------- 开事务:抓快照 ---------- */
int dm_begin_iso(uint64_t cid) {
dm_coro_state_t *s = dm_state_get_or_create(cid);
if (s->txn_active) return -1; /* 嵌套事务先拒绝 */
if (dpi_set_con_attr(s->conn, DSQL_ATTR_TXN_ISOLATION,
(dpointer)DSQL_TXN_SERIALIZABLE, 0) != DSQL_SUCCESS)
return -2;
/* 显式拿快照号,便于后面追踪 */
if (dpi_query_snapshot(s->conn, &s->snap_scn) != DSQL_SUCCESS) return -3;
s->txn_active = 1;
s->n_anchors = 0;
return 0;
}
/* ---------- 提交/回滚:释放版本链 ---------- */
int dm_end_iso(uint64_t cid, int commit) {
dm_coro_state_t *s = dm_state_get_or_create(cid);
if (!s->txn_active) return 0;
if (commit) dpi_commit(s->conn);
else dpi_rollback(s->conn);
/* 关键:通知DM8释放本协程快照可见的版本链 */
dpi_release_snapshot(s->conn, s->snap_scn);
/* 把所有锚点引用清掉。zval那侧通过弱引用机制感知 */
for (uint32_t i = 0; i < s->n_anchors; i++) {
dm_ver_node_t *v = s->anchors[i];
if (atomic_fetch_sub(&v->ref, 1) == 1) {
/* 自己是最后一个,可以告诉DM8物理回收 */
dpi_version_recycle(v->row_id, v->scn);
}
}
s->n_anchors = 0;
s->txn_active = 0;
s->snap_scn = 0;
return 0;
}
/* ---------- 协程销毁钩子(防泄漏关键) ---------- */
void dm_on_coro_destroy(uint64_t cid) {
dm_coro_state_t *s = dm_state_get_or_create(cid);
if (s->txn_active) {
/* 协程异常退出但事务还没收尾 →强制回滚,防版本链泄漏 */
fprintf(stderr, "[DM] coro %lu died with txn open, force rollback\n", cid);
dm_end_iso(cid, 0);
}
}
/* ---------- zval 弱引用代号机制 ---------- */
typedef struct {
uint64_t owner_cid; /* 谁开的事务 */
uint64_t snap_scn; /* 哪个快照下读出来 */
dm_ver_node_t *node; /* 指向的版本节点 */
_Atomic(uint8_t) valid; /* 1=有效 0=已失效 */
} dm_zval_handle_t;
/* PHP 资源类型 */
extern int le_dm_zval;
zval dm_fetch_zval(uint64_t cid, uint32_t row_id) {
dm_coro_state_t *s = dm_state_get_or_create(cid);
dm_ver_node_t *v = dpi_locate_version(row_id, s->snap_scn);
if (!v) { zval z; ZVAL_NULL(&z); return z; }
atomic_fetch_add(&v->ref, 1);
if (s->n_anchors < 1024) s->anchors[s->n_anchors++] = v;
dm_zval_handle_t *h = emalloc(sizeof(*h));
h->owner_cid = cid; h->snap_scn = s->snap_scn;
h->node = v; atomic_store(&h->valid, 1);
zval z;
ZVAL_RES(&z, zend_register_resource(h, le_dm_zval));
return z;
}
/* PHP 用 zval 时先校验 valid,避免野指针 */
void* dm_zval_data(zval *z) {
dm_zval_handle_t *h = zend_fetch_resource(Z_RES_P(z), "DMVer", le_dm_zval);
if (!h || !atomic_load(&h->valid)) return NULL;
return h->node->row_data;
}
/* zval 析构:弱引用断开 */
void dm_zval_dtor(zend_resource *res) {
dm_zval_handle_t *h = res->ptr;
if (atomic_load(&h->valid)) {
if (atomic_fetch_sub(&h->node->ref, 1) == 1)
dpi_version_recycle(h->node->row_id, h->node->scn);
}
efree(h);
}
接到 PHP 用法
PHP_FUNCTION(dm_begin) { dm_begin_iso(swoole_coroutine_get_cid()); }
PHP_FUNCTION(dm_commit){ dm_end_iso(swoole_coroutine_get_cid(), 1); }
PHP_FUNCTION(dm_rollback){dm_end_iso(swoole_coroutine_get_cid(), 0); }
/* 注册协程销毁钩子 */
PHP_MINIT_FUNCTION(dm_iso) {
swoole_coroutine_register_destroy_hook(dm_on_coro_destroy);
le_dm_zval = zend_register_list_destructors_ex(dm_zval_dtor, NULL, "DMVer", module_number);
return SUCCESS;
}
---
三、WebRTC媒体流零拷贝:PHP-FFI ×AVFrame 直接映射
大白话讲清楚问题
WebRTC 拉一路视频,每秒 30 帧 1080p,每帧 RGBA 大约 8MB。原来流程:
ffmpeg解码 →AVFrame (C内存)
→拷贝到zend_string (PHP堆)
→处理
→再拷贝回C →编码推流
一路流光拷贝就 480MB/s 起步,多路直接打爆。
怎么救:用 PHP-FFI 让 PHP 直接持有 AVFrame 指针,配合 mmap 共享内存做生命周期托管,PHP 看到的"图像 buffer"就是 ffmpeg
那块 C 内存本身,不复制。
完整代码
/* webrtc_bridge.c ——C 侧导出 FFI 接口 */
#include <libavformat/avformat.h>
#include <libavcodec/avcodec.h>
#include <libswscale/swscale.h>
#include <stdatomic.h>
/* PHP-FFI 见到的精简结构(必须和 cdef 一一对应) */
typedef struct {
uint8_t *data[8];
int linesize[8];
int width, height;
int format;
int64_t pts;
void *opaque; /* 内部 AVFrame* */
uint32_t refcount;
} ffi_frame_t;
typedef struct {
AVFormatContext *fmt;
AVCodecContext *cdc;
int vstream;
AVPacket *pkt;
AVFrame *frame;
} ffi_session_t;
/* ---------- 打开 WebRTC 流 ---------- */
ffi_session_t* webrtc_open(const char *url) {
ffi_session_t *s = calloc(1, sizeof(*s));
avformat_network_init();
if (avformat_open_input(&s->fmt, url, NULL, NULL) < 0) { free(s); return NULL; }
avformat_find_stream_info(s->fmt, NULL);
s->vstream = av_find_best_stream(s->fmt, AVMEDIA_TYPE_VIDEO, -1, -1, NULL, 0);
AVCodecParameters *par = s->fmt->streams[s->vstream]->codecpar;
const AVCodec *codec = avcodec_find_decoder(par->codec_id);
s->cdc = avcodec_alloc_context3(codec);
avcodec_parameters_to_context(s->cdc, par);
avcodec_open2(s->cdc, codec, NULL);
s->pkt = av_packet_alloc();
s->frame = av_frame_alloc();
return s;
}
/* ---------- 拉一帧。返回的 ffi_frame_t 直接复用 AVFrame buffer,零拷贝 ---------- */
ffi_frame_t* webrtc_next_frame(ffi_session_t *s) {
while (av_read_frame(s->fmt, s->pkt) >= 0) {
if (s->pkt->stream_index != s->vstream) { av_packet_unref(s->pkt); continue; }
if (avcodec_send_packet(s->cdc, s->pkt) < 0) { av_packet_unref(s->pkt); continue; }
if (avcodec_receive_frame(s->cdc, s->frame) == 0) {
ffi_frame_t *f = calloc(1, sizeof(*f));
for (int i = 0; i < 8; i++) {
f->data[i] = s->frame->data[i]; /* 同一块内存 */
f->linesize[i] = s->frame->linesize[i];
}
f->width = s->frame->width;
f->height = s->frame->height;
f->format = s->frame->format;
f->pts = s->frame->pts;
/* 抓一份 ref,AVFrame buffer 不会被 ffmpeg 内部回收 */
AVFrame *kept = av_frame_clone(s->frame); /* 这是引用计数+1,不拷数据 */
f->opaque = kept;
f->refcount = 1;
av_packet_unref(s->pkt);
return f;
}
av_packet_unref(s->pkt);
}
return NULL;
}
/* PHP 用完调这个,自动 unref。AVFrame 内部 refcount 到 0 才真释放 */
void webrtc_frame_release(ffi_frame_t *f) {
if (!f) return;
if (f->opaque) {
AVFrame *kept = (AVFrame*)f->opaque;
av_frame_free(&kept);
}
free(f);
}
void webrtc_close(ffi_session_t *s) {
if (!s) return;
av_frame_free(&s->frame);
av_packet_free(&s->pkt);
avcodec_free_context(&s->cdc);
avformat_close_input(&s->fmt);
free(s);
}
/* ---------- 把帧推回去:PHP 修改完 buffer 后直接 wrap 回 AVFrame ---------- */
int webrtc_push_frame(ffi_session_t *out, ffi_frame_t *f) {
AVFrame *src = (AVFrame*)f->opaque;
/* 再次 send_frame,不拷贝 */
return avcodec_send_frame(out->cdc, src);
}
PHP 侧用 FFI 调用
<?php
$ffi = FFI::cdef("
typedef struct {
uint8_t *data[8];
int linesize[8];
int width, height;
int format;
int64_t pts;
void *opaque;
uint32_t refcount;
} ffi_frame_t;
typedef struct ffi_session ffi_session_t;
ffi_session_t* webrtc_open(const char *url);
ffi_frame_t* webrtc_next_frame(ffi_session_t *s);
void webrtc_frame_release(ffi_frame_t *f);
void webrtc_close(ffi_session_t *s);
int webrtc_push_frame(ffi_session_t *out, ffi_frame_t *f);
", "libwebrtc_bridge.so");
$sess = $ffi->webrtc_open("webrtc://room/abc");
while (true) {
$frame = $ffi->webrtc_next_frame($sess);
if (FFI::isNull($frame)) break;
// 关键:直接操作 ffmpeg 那块 C 内存,零拷贝
$w = $frame->width; $h = $frame->height;
$stride = $frame->linesize[0];
$plane = $frame->data[0];
// 比如把整帧Y平面亮度+10:
for ($y = 0; $y < $h; $y++) {
for ($x = 0; $x < $w; $x++) {
$px = $plane[$y * $stride + $x];
$plane[$y * $stride + $x] = min(255, $px + 10);
}
}
// 上面是示意,真实场景用 FFI::memcpy 或者批处理函数避免逐字节循环
// 用完归还引用,buffer 才可能被 ffmpeg 回收
$ffi->webrtc_frame_release($frame);
}
$ffi->webrtc_close($sess);
配合 TSRM:协程级 session 隔离
/* 让每个协程一路拉流互不干扰,session 放协程储物柜 */
extern int g_webrtc_session_rid;
ffi_session_t* webrtc_get_session(uint64_t cid) {
ffi_session_t *s = lftsrm_resource_get(g_webrtc_session_rid);
return s; /* 不存在就让 PHP 调 webrtc_open 后 set 进去 */
}
/* 协程销毁时强制释放 ffmpeg 资源,否则 socket/buffer 一直挂着 */
void webrtc_on_coro_destroy(uint64_t cid) {
ffi_session_t *s = lftsrm_resource_get(g_webrtc_session_rid);
if (s) webrtc_close(s);
}
---
三块串联起来怎么跑
[Rust 实时调度器]
│ 提交协程
▼
PHP worker 线程 (绑核+NUMA亲和)
│
├─ 协程A:rt_go(handle_video_frame, deadline=2ms)
│ └─ FFI 调用 webrtc_next_frame ←零拷贝拿AVFrame
│ 处理完 webrtc_frame_release ←引用计数自动释放
│
├─ 协程B:dm_begin →查DM8 →dm_commit
│ └─ TSRM 协程储物柜里存 snap_scn + 版本链锚点
│ 异常退出时 dm_on_coro_destroy 自动回滚防泄漏
│
└─ 协程C:普通业务,rt_go() 走 work-stealing 队列
编译命令:
# Rust 调度器
cd rt_scheduler && cargo build --release # libtl_rt_scheduler.so
# C 桥 + 扩展
gcc -O2 -fPIC -pthread -D_GNU_SOURCE \
php_rt_sched.c dm_mvcc_iso.c webrtc_bridge.c \
-ldpiapi -lavformat -lavcodec -lavutil -lswscale \
-L./rt_scheduler/target/release -lrt_scheduler \
-shared -o xc_php_rtdm.so
# php.ini
# extension=xc_php_rtdm.so
# ffi.enable=1
大白话总结
- 第一块:把调度器从 C 搬到 Rust,借编译期的所有权检查根除野指针;加一条实时队列保证音视频/工控这种 deadline
任务不被普通协程拖累。
- 第二块:DM8 的 MVCC 版本链不是它泄漏,是 PHP 这边没把"协程退出"和"快照释放"绑起来。把状态塞进协程级
TSRM,加销毁钩子和弱引用代号,泄漏和野指针一锅端。
- 第三块:WebRTC 媒体流的瓶颈是拷贝,不是计算。FFI 让 PHP 直接看 AVFrame 指针,加 av_frame_clone
这种"加引用不拷数据"的招式,1080p30 单流内存带宽从几百 MB/s 降到接近零。
火山引擎视频云技术社区,是面向 AI 音视频开发者的技术交流平台。这里汇聚源自抖音、豆包等亿级 DAU 产品的 RTC、直播、点播、AI 媒体处理、音视频互动技术,提供接入指南、最佳实践、性能调优、场景案例、Demo 代码、开源项目、白皮书和 API 文档。社区汇聚官方工程师与一线开发者,为 AI 视频通话、数字人、AI 视频处理等应用的开发与落地提供技术支持。
更多推荐
所有评论(0)